feature/AS-timbrado-cfdi-ingreso #10

Merged
jcedilloAS merged 4 commits from feature/AS-timbrado-cfdi-ingreso into main 2026-08-11 15:09:03 +00:00
40 changed files with 4896 additions and 16 deletions

View File

@@ -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=

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
@@ -92,6 +93,9 @@ class InvoiceBase(BaseModel):
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
# Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
# ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
class InvoiceCreate(InvoiceBase):
@@ -114,6 +118,7 @@ class InvoiceUpdate(BaseModel):
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
stamping_mode: Literal["pruebas", "produccion"] | None = None
class InvoiceResponse(InvoiceBase):
@@ -140,3 +145,26 @@ class InvoiceResponse(InvoiceBase):
company_id: int
created_at: datetime
updated_at: datetime
class InvoiceItemTaxInput(BaseModel):
"""Alta o ajuste de un impuesto de la partida.
El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
pueda quedar un desglose que no cuadre con el importe del concepto.
"""
tax_id: int
rate: Decimal = Field(..., ge=0, le=1, max_digits=8, decimal_places=6)
is_withholding: bool = False
class InvoiceItemTaxResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_item_id: int
tax_id: int
is_withholding: bool
rate: Decimal | None = None
amount: Decimal

View File

@@ -74,6 +74,13 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
)
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
# ----- Modo de timbrado (por factura, no por entorno) -----
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
# cambiarlo después falsearía el registro de con qué intención se emitió.
stamping_mode: Mapped[str] = mapped_column(
String(12), nullable=False, server_default=text("'pruebas'")
)
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):

View File

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

View File

@@ -20,6 +20,7 @@ from .dto import (
InvoiceUpdate,
PaymentCreate,
)
from . import taxes_service
from .models import Invoice, InvoiceItem, Payment
from .pdf import build_invoice_pdf
@@ -119,16 +120,43 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
obj = get_invoice(db, invoice_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
_reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
for f, v in data.items():
setattr(obj, f, v)
obj.updated_by = user_id
db.flush()
if "tax_rate" in data:
# El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
taxes_service.sync_invoice_taxes(db, obj)
_recompute(db, obj) # tax_rate pudo cambiar
db.commit()
db.refresh(obj)
return obj
def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
"""El modo de timbrado es inmutable una vez que la factura tiene timbre.
Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
hecho no se edita.
"""
nuevo = data.get("stamping_mode")
if nuevo is None or nuevo == obj.stamping_mode:
return
# Import diferido: stamping importa invoices, y al revés sería circular.
from ..stamping.service import get_stamp # noqa: PLC0415
if get_stamp(db, obj.id, tenant_id, company_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"La factura ya está timbrada: el modo de timbrado no se puede cambiar "
f"(sigue en {obj.stamping_mode!r})."
),
)
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
@@ -391,6 +419,7 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
taxes_service.sync_item_taxes(db, item, invoice)
_recompute(db, invoice)
db.commit()
db.refresh(item)
@@ -407,7 +436,9 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
for f, v in data.items():
setattr(item, f, v)
db.flush()
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
taxes_service.sync_item_taxes(db, item, invoice)
_recompute(db, invoice)
db.commit()
db.refresh(item)
return item

View File

@@ -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()

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""

View File

@@ -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
# (<?xml version='1.0' encoding='UTF-8'?>), 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'<?xml version="1.0" encoding="UTF-8"?>\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)

View File

@@ -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

View File

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

View File

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

View File

@@ -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}

View File

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

View File

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

View File

@@ -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.

View File

@@ -0,0 +1,409 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions" xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:cce11="http://www.sat.gob.mx/ComercioExterior11" xmlns:cce20="http://www.sat.gob.mx/ComercioExterior20" xmlns:donat="http://www.sat.gob.mx/donat" xmlns:divisas="http://www.sat.gob.mx/divisas" xmlns:implocal="http://www.sat.gob.mx/implocal" xmlns:leyendasFisc="http://www.sat.gob.mx/leyendasFiscales" xmlns:pfic="http://www.sat.gob.mx/pfic" xmlns:tpe="http://www.sat.gob.mx/TuristaPasajeroExtranjero" xmlns:nomina12="http://www.sat.gob.mx/nomina12" xmlns:registrofiscal="http://www.sat.gob.mx/registrofiscal" xmlns:pagoenespecie="http://www.sat.gob.mx/pagoenespecie" xmlns:aerolineas="http://www.sat.gob.mx/aerolineas" xmlns:valesdedespensa="http://www.sat.gob.mx/valesdedespensa" xmlns:notariospublicos="http://www.sat.gob.mx/notariospublicos" xmlns:vehiculousado="http://www.sat.gob.mx/vehiculousado" xmlns:servicioparcial="http://www.sat.gob.mx/servicioparcialconstruccion" xmlns:decreto="http://www.sat.gob.mx/renovacionysustitucionvehiculos" xmlns:destruccion="http://www.sat.gob.mx/certificadodestruccion" xmlns:obrasarte="http://www.sat.gob.mx/arteantiguedades" xmlns:ine="http://www.sat.gob.mx/ine" xmlns:iedu="http://www.sat.gob.mx/iedu" xmlns:ventavehiculos="http://www.sat.gob.mx/ventavehiculos" xmlns:detallista="http://www.sat.gob.mx/detallista" xmlns:ecc12="http://www.sat.gob.mx/EstadoDeCuentaCombustible12" xmlns:consumodecombustibles11="http://www.sat.gob.mx/ConsumoDeCombustibles11" xmlns:gceh="http://www.sat.gob.mx/GastosHidrocarburos10" xmlns:ieeh="http://www.sat.gob.mx/IngresosHidrocarburos10" xmlns:cartaporte20="http://www.sat.gob.mx/CartaPorte20" xmlns:pago20="http://www.sat.gob.mx/Pagos20" xmlns:cartaporte30="http://www.sat.gob.mx/CartaPorte30" xmlns:cartaporte31="http://www.sat.gob.mx/CartaPorte31" xmlns:hidrocarburospetroliferos="http://www.sat.gob.mx/hidrocarburospetroliferos">
<!-- Con el siguiente método se establece que la salida deberá ser en texto -->
<xsl:output method="text" version="1.0" encoding="UTF-8" indent="no"/>
<!--
En esta sección se define la inclusión de las plantillas de utilerías para colapsar espacios
-->
<xsl:include href="utilerias.xslt"/>
<!--
En esta sección se define la inclusión de las demás plantillas de transformación para
la generación de las cadenas originales de los complementos fiscales
-->
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<!-- Aquí iniciamos el procesamiento de la cadena original con su | inicial y el terminador || -->
<xsl:template match="/">|<xsl:apply-templates select="/cfdi:Comprobante"/>||</xsl:template>
<!-- Aquí iniciamos el procesamiento de los datos incluidos en el comprobante -->
<xsl:template match="cfdi:Comprobante">
<!-- Iniciamos el tratamiento de los atributos de comprobante -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Version"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Serie"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Folio"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Fecha"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@FormaPago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NoCertificado"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@CondicionesDePago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@SubTotal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Descuento"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Moneda"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TipoCambio"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Total"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoDeComprobante"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Exportacion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@MetodoPago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@LugarExpedicion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Confirmacion"/>
</xsl:call-template>
<!--
Llamadas para procesar al los sub nodos del comprobante
-->
<xsl:apply-templates select="./cfdi:InformacionGlobal"/>
<xsl:for-each select="./cfdi:CfdiRelacionados">
<xsl:apply-templates select="."/>
</xsl:for-each>
<xsl:apply-templates select="./cfdi:Emisor"/>
<xsl:apply-templates select="./cfdi:Receptor"/>
<xsl:apply-templates select="./cfdi:Conceptos"/>
<xsl:apply-templates select="./cfdi:Impuestos"/>
<xsl:apply-templates select="./cfdi:Complemento"/>
</xsl:template>
<!-- Manejador de nodos tipo InformacionGlobal -->
<xsl:template match="cfdi:InformacionGlobal">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo InformacionGlobal -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Periodicidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Meses"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Año"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo CFDIRelacionados -->
<xsl:template match="cfdi:CfdiRelacionados">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo CFDIRelacionados -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoRelacion"/>
</xsl:call-template>
<xsl:for-each select="./cfdi:CfdiRelacionado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@UUID"/>
</xsl:call-template>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Emisor -->
<xsl:template match="cfdi:Emisor">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Emisor -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Rfc"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Nombre"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@FacAtrAdquirente"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Receptor -->
<xsl:template match="cfdi:Receptor">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Receptor -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Rfc"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Nombre"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@DomicilioFiscalReceptor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@ResidenciaFiscal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NumRegIdTrib"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscalReceptor"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@UsoCFDI"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Conceptos -->
<xsl:template match="cfdi:Conceptos">
<!-- Llamada para procesar los distintos nodos tipo Concepto -->
<xsl:for-each select="./cfdi:Concepto">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!--Manejador de nodos tipo Concepto-->
<xsl:template match="cfdi:Concepto">
<!-- Iniciamos el tratamiento de los atributos del Concepto -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Cantidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveUnidad"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Unidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Descripcion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ValorUnitario"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Descuento"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ObjetoImp"/>
</xsl:call-template>
<!-- Manejo de sub nodos de información Traslado de Conceptos:Concepto:Impuestos:Traslados-->
<xsl:for-each select="./cfdi:Impuestos/cfdi:Traslados/cfdi:Traslado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Manejo de sub nodos de Retencion por cada una de los Conceptos:Concepto:Impuestos:Retenciones-->
<xsl:for-each select="./cfdi:Impuestos/cfdi:Retenciones/cfdi:Retencion">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Manejo de los distintos sub nodos a cuenta de terceros de forma indistinta a su grado de dependencia -->
<xsl:for-each select="./cfdi:ACuentaTerceros">
<xsl:apply-templates select="."/>
</xsl:for-each>
<!-- Manejo de los distintos sub nodos de información aduanera de forma indistinta a su grado de dependencia -->
<xsl:for-each select="./cfdi:InformacionAduanera">
<xsl:apply-templates select="."/>
</xsl:for-each>
<!-- Llamada al manejador de nodos de CuentaPredial en caso de existir -->
<xsl:if test="./cfdi:CuentaPredial">
<xsl:apply-templates select="./cfdi:CuentaPredial"/>
</xsl:if>
<!-- Llamada al manejador de nodos de ComplementoConcepto en caso de existir -->
<xsl:if test="./cfdi:ComplementoConcepto">
<xsl:apply-templates select="./cfdi:ComplementoConcepto"/>
</xsl:if>
<!-- Llamada al manejador de nodos de Parte en caso de existir -->
<xsl:for-each select=".//cfdi:Parte">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo ACuentaTerceros -->
<xsl:template match="cfdi:ACuentaTerceros">
<!-- Manejo de los atributos del nodo tipo ACuentaTerceros -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RfcACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NombreACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscalACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@DomicilioFiscalACuentaTerceros"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Información Aduanera -->
<xsl:template match="cfdi:InformacionAduanera">
<!-- Manejo de los atributos de la información aduanera -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NumeroPedimento"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Información CuentaPredial -->
<xsl:template match="cfdi:CuentaPredial">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Numero"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo ComplementoConcepto -->
<xsl:template match="cfdi:ComplementoConcepto">
<xsl:for-each select="./*">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Parte -->
<xsl:template match="cfdi:Parte">
<!-- Iniciamos el tratamiento de los atributos de Parte-->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Cantidad"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Unidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Descripcion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@ValorUnitario"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
<!-- Manejador de nodos tipo InformacionAduanera-->
<xsl:for-each select=".//cfdi:InformacionAduanera">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Complemento -->
<xsl:template match="cfdi:Complemento">
<xsl:for-each select="./*">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Domicilio fiscal -->
<xsl:template match="cfdi:Impuestos">
<!-- Manejo de sub nodos de Retencion por cada una de los Impuestos:Retenciones-->
<xsl:for-each select="./cfdi:Retenciones/cfdi:Retencion">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosRetenidos-->
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TotalImpuestosRetenidos"/>
</xsl:call-template>
<!-- Manejo de sub nodos de información Traslado de Impuestos:Traslados-->
<xsl:for-each select="./cfdi:Traslados/cfdi:Traslado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosTrasladados-->
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TotalImpuestosTrasladados"/>
</xsl:call-template>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Marcador de posicion para los complementos del CFDI que este modulo NO emite.
cadenaoriginal_4_0.xslt del SAT incluye 32 hojas de estilo de complementos (Carta Porte,
Comercio Exterior, Nomina, Pagos...). Cada una solo aporta plantillas que hacen match sobre
nodos de su complemento: si el comprobante no los lleva, nunca se invocan y su ausencia no
cambia la cadena original ni un caracter.
Este modulo emite CFDI 4.0 tipo ingreso SIN complementos (ver el plan del ticket, seccion 1),
asi que todos esos includes apuntan aqui. Si algun dia se soporta un complemento, hay que
traer SU xslt oficial y apuntar el href de ese include al archivo real.
-->
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions">
<!-- Manejador de datos requeridos -->
<xsl:template name="Requerido">
<xsl:param name="valor"/>|<xsl:call-template name="ManejaEspacios">
<xsl:with-param name="s" select="$valor"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de datos opcionales -->
<xsl:template name="Opcional">
<xsl:param name="valor"/>
<xsl:if test="$valor">|<xsl:call-template name="ManejaEspacios"><xsl:with-param name="s" select="$valor"/></xsl:call-template></xsl:if>
</xsl:template>
<!-- Normalizador de espacios en blanco -->
<xsl:template name="ManejaEspacios">
<xsl:param name="s"/>
<xsl:value-of select="normalize-space(string($s))"/>
</xsl:template>
</xsl:stylesheet>

View File

@@ -116,6 +116,24 @@ class Settings(BaseSettings):
S3_FILE_STORAGE: bool = True
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
# ----- PAC Comercio Digital (timbrado CFDI) -----
# El modo NO se configura aquí: vive en fin.invoices.stamping_mode, por factura. Esta
# variable sólo define con qué valor nacen las facturas que no lo especifican.
PAC_DEFAULT_MODE: Literal["pruebas", "produccion"] = "pruebas"
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
PAC_USER: str = "" # header usrws
PAC_PASSWORD: str = "" # header pwdws
PAC_TIMEOUT_SECONDS: int = 15
PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
# Clave maestra que cifra las contraseñas de los CSD guardadas en la base (ver core/crypto).
# Sin ella no se pueden cargar certificados. Se genera con:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CSD_ENCRYPTION_KEY: str = ""
# Contraseña global del CSD. LEGADO: sólo se usa como respaldo si una empresa no tiene su
# propia contraseña guardada. Lo correcto es cargar el CSD por empresa.
CSD_PASSWORD: str = ""
# ── EFC (expediente electrónico) ────────────────────────────────────────────────────────
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar

77
backend/core/crypto.py Normal file
View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -44,6 +44,7 @@ import api.v1.modules.fin.catalogs.models # noqa: E402,F401
import api.v1.modules.fin.concepts.models # noqa: E402,F401
import api.v1.modules.fin.issuer.models # noqa: E402,F401
import api.v1.modules.fin.invoices.models # noqa: E402,F401
import api.v1.modules.fin.stamping.models # noqa: E402,F401
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}

View File

@@ -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

View File

@@ -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 version="1.0" encoding="UTF-8"?>'
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"<x>" + b"a" * 300 + b"</x>"
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="<cfdi/>"):
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"<x/>",
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="<fault/>"))
# El cuerpo se conserva: es lo que se guarda como XML de respuesta del intento.
assert res.xml == "<fault/>"
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"<enviado/>", "<recibido/>")
assert [cuerpo for _, cuerpo in subidas] == [b"<enviado/>", b"<recibido/>"]
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"<enviado/>", "")
assert [cuerpo for _, cuerpo in subidas] == [b"<enviado/>"]
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"<enviado/>", "<recibido/>") # no propaga
assert stamp.request_xml_file_key is None
assert stamp.response_xml_file_key is None

View File

@@ -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

View File

@@ -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 | 67 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_<modulo>.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 1213 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 1213 | 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 67: 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 1213 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 '<ruta>' noche"` llegaba partido y `bash -lc bash <ruta> 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 "<ruta>\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.

View File

@@ -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;

View File

@@ -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<IssuerSettings> {
const fd = new FormData();
fd.append('cer', cer);
fd.append('key', key);
fd.append('password', password);
const res = await api.request<IssuerSettings>(
`/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<IssuerSettings> {
const res = await api.delete<IssuerSettings>(
`/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!;
}
};

View File

@@ -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<InvoiceStamp> => {
const res = await api.post<InvoiceStamp>(`/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<InvoiceStamp | null> => {
const res = await api.get<InvoiceStamp>(`/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<string> => {
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;
}
};

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck, Stamp, FileCode, AlertTriangle } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI, stampingAPI, satCatalogsAPI, MissingFiscalDataError,
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput, type InvoiceStamp,
type SatCatalogItem, type SatUnitOfMeasure
} from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
@@ -34,6 +35,22 @@
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
/** Opción elegida en el selector de concepto: `cat:<id>` del catálogo o `txt:<clave>` genérica. */
let conceptChoice = $state('txt:flete_internacional');
/** Timbre vigente de la factura; `null` mientras no esté timbrada. */
let stamp = $state<InvoiceStamp | null>(null);
let stamping = $state(false);
/** Datos fiscales que el backend reportó como faltantes en el último intento. */
let missing = $state<string[]>([]);
// Catálogos SAT para los selectores fiscales. Se cargan una vez con la factura.
let paymentForms = $state<SatCatalogItem[]>([]);
let paymentMethods = $state<SatCatalogItem[]>([]);
let taxObjects = $state<SatCatalogItem[]>([]);
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
let productsServices = $state<SatCatalogItem[]>([]);
/** Partida cuyas claves fiscales se están editando. */
let fiscalItem = $state<InvoiceItem | null>(null);
let fiscalForm = $state<{ product_service_id: number | null; unit_of_measure_id: number | null; tax_object_id: number | null }>({
product_service_id: null, unit_of_measure_id: null, tax_object_id: null
});
const activeConcepts = $derived(concepts.filter((c) => c.is_active));
@@ -42,14 +59,19 @@
const id = invoiceId;
if (!cid || !id) return;
void load(cid, id);
void loadStamp(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[invoice, items, payments, accounts, concepts] = await Promise.all([
[invoice, items, payments, accounts, concepts,
paymentForms, paymentMethods, taxObjects, unitsOfMeasure, productsServices] = await Promise.all([
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
accountsAPI.list(cid), conceptsAPI.list(cid)
accountsAPI.list(cid), conceptsAPI.list(cid),
satCatalogsAPI.paymentForms(cid), satCatalogsAPI.paymentMethods(cid),
satCatalogsAPI.taxObjects(cid), satCatalogsAPI.unitsOfMeasure(cid),
satCatalogsAPI.productsServices(cid)
]);
form = { ...invoice };
} catch (e) {
@@ -135,6 +157,82 @@
}
}
async function loadStamp(cid: number, id: number) {
try {
stamp = await stampingAPI.get(id, cid);
} catch {
// Consultar el timbre es informativo: si falla, la pantalla sigue siendo usable.
stamp = null;
}
}
async function doStamp() {
if (!companyId || !invoice) return;
if (invoice.stamping_mode === 'produccion') {
// Producción emite un CFDI con validez fiscal real ante el SAT y deshacerlo obliga a
// cancelarlo: no puede dispararse con un clic distraído.
const ok = window.confirm(
'Esta factura está en modo PRODUCCIÓN.\n\n' +
'Se emitirá un CFDI con validez fiscal real ante el SAT y para deshacerlo habrá que ' +
'cancelarlo.\n\n¿Continuar?'
);
if (!ok) return;
}
missing = [];
stamping = true;
try {
stamp = await stampingAPI.stamp(invoice.id, companyId);
toast.success(`Comprobante timbrado — UUID ${stamp.uuid}`);
await reload();
} catch (e) {
if (e instanceof MissingFiscalDataError) {
// Se pintan en la pantalla, no en un toast: son varios y hay que ir a capturarlos.
missing = e.missing;
toast.error(e.message);
} else {
toast.error(e instanceof Error ? e.message : 'No se pudo timbrar');
}
} finally {
stamping = false;
}
}
async function openStampXml() {
if (!companyId || !invoice) return;
try {
const url = await stampingAPI.xmlUrl(invoice.id, companyId);
window.open(url, '_blank', 'noopener');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el XML');
}
}
function startFiscal(it: InvoiceItem) {
fiscalItem = it;
fiscalForm = {
product_service_id: it.product_service_id ?? null,
unit_of_measure_id: it.unit_of_measure_id ?? null,
tax_object_id: it.tax_object_id ?? null
};
}
async function saveFiscal() {
if (!companyId || !fiscalItem) return;
busy = true;
try {
// El backend deriva el IVA de la partida al guardar: si el objeto de impuesto pasa a
// '02', el traslado aparece solo con el % de la factura.
await invoiceItemsAPI.update(fiscalItem.id, fiscalForm, companyId);
fiscalItem = null;
await reload();
toast.success('Claves fiscales actualizadas');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar las claves');
} finally {
busy = false;
}
}
function startItem() {
newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 };
// Si la empresa ya tiene catálogo, se arranca con su primer concepto.
@@ -216,10 +314,56 @@
<Button size="sm" variant="outline" onclick={() => clientDecision(true)} disabled={busy}><Check class="mr-1 h-4 w-4 text-emerald-600" /> Cliente aprueba</Button>
<Button size="sm" variant="outline" onclick={() => clientDecision(false)} disabled={busy}><X class="mr-1 h-4 w-4 text-destructive" /> Con observaciones</Button>
{/if}
{#if invoice.status !== 'borrador' && invoice.status !== 'cancelada' && !stamp}
<Button size="sm" onclick={doStamp} disabled={stamping || busy}>
<Stamp class="mr-1 h-4 w-4" /> {stamping ? 'Timbrando…' : 'Timbrar'}
</Button>
{/if}
{#if stamp?.xml_file_key}<Button size="sm" variant="outline" onclick={openStampXml}><FileCode class="mr-1 h-4 w-4" /> XML timbrado</Button>{/if}
{#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}<Button size="sm" variant="outline" onclick={() => doAction('cancel')} disabled={busy}><X class="mr-1 h-4 w-4" /> Cancelar</Button>{/if}
</div>
</div>
{#if stamp}
<!-- Comprobante ya timbrado: el UUID es el identificador ante el SAT. -->
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
<p class="flex flex-wrap items-center gap-2 font-medium text-emerald-900 dark:text-emerald-100">
<Stamp class="h-4 w-4" /> Comprobante timbrado
{#if stamp.mode === 'pruebas'}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900 dark:bg-amber-900 dark:text-amber-100">
PRUEBAS — sin validez fiscal
</span>
{/if}
</p>
<dl class="mt-2 grid gap-x-6 gap-y-1 text-xs text-emerald-900 sm:grid-cols-2 dark:text-emerald-200">
<div><dt class="inline font-medium">UUID:</dt> <dd class="inline font-mono">{stamp.uuid}</dd></div>
<div><dt class="inline font-medium">Fecha de timbrado:</dt> <dd class="inline">{stamp.stamped_at ?? '—'}</dd></div>
<div><dt class="inline font-medium">PAC:</dt> <dd class="inline">{stamp.pac_rfc ?? '—'}</dd></div>
{#if stamp.pac_balance !== null}
<div><dt class="inline font-medium">Folios restantes:</dt> <dd class="inline">{stamp.pac_balance}</dd></div>
{/if}
</dl>
{#if stamp.error_message}
<p class="mt-2 text-xs text-amber-800 dark:text-amber-200">Aviso: {stamp.error_message}</p>
{/if}
</div>
{/if}
{#if missing.length}
<!-- El backend devuelve TODOS los faltantes de una vez, para capturarlos en una pasada. -->
<div class="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<p class="flex items-center gap-2 font-medium text-destructive">
<AlertTriangle class="h-4 w-4" /> Faltan datos fiscales para timbrar
</p>
<ul class="mt-2 list-disc space-y-0.5 pl-6 text-xs text-muted-foreground">
{#each missing as m (m)}<li>{m}</li>{/each}
</ul>
<p class="mt-2 text-xs text-muted-foreground">
Captúralos en la factura, en sus partidas o en la ficha del cliente y vuelve a intentar.
</p>
</div>
{/if}
{#if invoice.client_reviewed_at}
<p class="text-xs text-muted-foreground">Revisión del cliente: {invoice.client_approved ? 'aprobada' : 'con observaciones'}{#if invoice.review_notes} — {invoice.review_notes}{/if}</p>
{/if}
@@ -265,22 +409,96 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
<!-- Claves del SAT de la partida. Vienen del concepto del catálogo cuando lo hay,
y se pueden ajustar aquí; sin ellas no se puede timbrar. -->
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave producto/servicio (SAT)</span>
<select class={inputCls} bind:value={newItem.product_service_id}>
<option value={null}>Selecciona…</option>
{#each productsServices as ps (ps.id)}<option value={ps.id}>{ps.code} {ps.description}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave de unidad (SAT)</span>
<select class={inputCls} bind:value={newItem.unit_of_measure_id}>
<option value={null}>Selecciona…</option>
{#each unitsOfMeasure as u (u.id)}<option value={u.id}>{u.code} {u.description}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Objeto de impuesto (SAT)</span>
<select class={inputCls} bind:value={newItem.tax_object_id}>
<option value={null}>Selecciona…</option>
{#each taxObjects as o (o.id)}<option value={o.id}>{o.code} {o.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">
Con «02 — Sí objeto de impuesto» el IVA se calcula solo, con el % de la factura.
</span>
</label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
</div>
{/if}
{#if fiscalItem}
<!-- Claves del SAT de una partida ya creada: es lo que faltaba para poder timbrar
partidas dadas de alta antes de que existieran estos campos. -->
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<p class="text-sm font-medium sm:col-span-2">
Claves fiscales de «{itemConceptLabel(fiscalItem)}»
</p>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave producto/servicio (SAT)</span>
<select class={inputCls} bind:value={fiscalForm.product_service_id}>
<option value={null}>Selecciona…</option>
{#each productsServices as ps (ps.id)}<option value={ps.id}>{ps.code} {ps.description}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave de unidad (SAT)</span>
<select class={inputCls} bind:value={fiscalForm.unit_of_measure_id}>
<option value={null}>Selecciona…</option>
{#each unitsOfMeasure as u (u.id)}<option value={u.id}>{u.code} {u.name}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Objeto de impuesto (SAT)</span>
<select class={inputCls} bind:value={fiscalForm.tax_object_id}>
<option value={null}>Selecciona…</option>
{#each taxObjects as o (o.id)}<option value={o.id}>{o.code} {o.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">
Con «02 — Sí objeto de impuesto» el IVA se calcula solo, usando el {invoice.tax_rate}%
de la factura.
</span>
</label>
<div class="flex justify-end gap-2 sm:col-span-2">
<Button variant="outline" size="sm" onclick={() => (fiscalItem = null)}>Cancelar</Button>
<Button size="sm" onclick={saveFiscal} disabled={busy}>Guardar claves</Button>
</div>
</div>
{/if}
{#if items.length === 0}
<p class="text-sm text-muted-foreground">Sin conceptos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Claves SAT</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{itemConceptLabel(it)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="text-xs">
{#if it.product_service_id && it.unit_of_measure_id && it.tax_object_id}
<span class="text-emerald-600">completas</span>
{:else}
<button type="button" class="text-destructive underline" onclick={() => startFiscal(it)}>faltan claves</button>
{/if}
</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" onclick={() => startFiscal(it)} aria-label="Claves fiscales"><Receipt class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -323,6 +541,47 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<!-- Claves fiscales del comprobante. Sin ellas el timbrado se detiene en la
validación, antes de llegar al PAC. -->
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Forma de pago (SAT) *</span>
<select class={inputCls} bind:value={form.payment_form_id}>
<option value={null}>Selecciona…</option>
{#each paymentForms as f (f.id)}<option value={f.id}>{f.code} {f.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia…</span>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Método de pago (SAT) *</span>
<select class={inputCls} bind:value={form.payment_method_id}>
<option value={null}>Selecciona…</option>
{#each paymentMethods as m (m.id)}<option value={m.id}>{m.code} {m.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades.</span>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">CP del lugar de expedición</span>
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} />
<span class="text-xs text-muted-foreground">Si se deja vacío se usa el del emisor.</span>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Modo de timbrado</span>
<select class={inputCls} bind:value={form.stamping_mode} disabled={!!stamp}>
<option value="pruebas">Pruebas — el comprobante no tiene validez fiscal</option>
<option value="produccion">Producción — emite un CFDI real ante el SAT</option>
</select>
<span class="text-xs text-muted-foreground">
{#if stamp}
La factura ya está timbrada: el modo no se puede cambiar.
{:else}
Determina a qué entorno del PAC se transmite. En producción el comprobante tiene
validez fiscal y deshacerlo obliga a cancelarlo ante el SAT.
{/if}
</span>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Receipt } from '@lucide/svelte';
import { Receipt, ShieldCheck, Upload, Trash2, AlertTriangle } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
@@ -8,6 +8,7 @@
issuerAPI,
satCatalogsAPI,
RFC_REGEX,
type IssuerSettings,
type IssuerSettingsInput,
type SatTaxRegime
} from '$lib/api/fin';
@@ -25,6 +26,12 @@
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
let isNew = $state(true);
let rfcError = $state('');
/** Estado del CSD cargado; null mientras no haya datos fiscales. */
let issuer = $state<IssuerSettings | null>(null);
let cerFile = $state<File | null>(null);
let keyFile = $state<File | null>(null);
let csdPassword = $state('');
let uploadingCsd = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
@@ -45,6 +52,7 @@
]);
taxRegimes = regimes;
isNew = settings === null;
issuer = settings;
if (settings) {
form = {
legal_name: settings.legal_name,
@@ -82,7 +90,7 @@
saving = true;
try {
await issuerAPI.save(
issuer = await issuerAPI.save(
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
cid
);
@@ -95,6 +103,42 @@
}
}
async function uploadCsd(event: SubmitEvent) {
event.preventDefault();
const cid = companyId;
if (!cid || !cerFile || !keyFile) return;
uploadingCsd = true;
try {
issuer = await issuerAPI.uploadCsd(cerFile, keyFile, csdPassword, cid);
// La contraseña no se conserva en la pantalla: ya está cifrada en el servidor y
// dejarla en memoria del navegador no aporta nada.
csdPassword = '';
cerFile = null;
keyFile = null;
toast.success('Certificado cargado y verificado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el certificado');
} finally {
uploadingCsd = false;
}
}
async function removeCsd() {
const cid = companyId;
if (!cid) return;
if (!window.confirm('Se quitará el certificado y la empresa dejará de poder timbrar. ¿Continuar?'))
return;
uploadingCsd = true;
try {
issuer = await issuerAPI.deleteCsd(cid);
toast.success('Certificado retirado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo quitar el certificado');
} finally {
uploadingCsd = false;
}
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
@@ -196,5 +240,115 @@
{/if}
</Card.Content>
</Card.Root>
<!-- ===== Certificado de Sello Digital ===== -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5" /> Certificado de Sello Digital (CSD)
</Card.Title>
<Card.Description>
Es lo que firma los comprobantes ante el SAT. Sin él no se puede timbrar.
</Card.Description>
</Card.Header>
<Card.Content>
{#if isNew}
<p class="py-4 text-sm text-muted-foreground">
Primero guarda los datos fiscales del emisor: el certificado se asocia a ellos.
</p>
{:else}
{#if issuer?.has_csd}
<div class="mb-4 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
<p class="flex items-center gap-2 font-medium text-emerald-900 dark:text-emerald-100">
<ShieldCheck class="h-4 w-4" /> Certificado cargado
</p>
<dl class="mt-2 grid gap-x-6 gap-y-1 text-xs text-emerald-900 sm:grid-cols-2 dark:text-emerald-200">
<div>
<dt class="inline font-medium">No. de certificado:</dt>
<dd class="inline font-mono">{issuer.csd_cert_number}</dd>
</div>
<div>
<dt class="inline font-medium">Cargado el:</dt>
<dd class="inline">{issuer.csd_uploaded_at}</dd>
</div>
</dl>
{#if canEdit}
<Button
size="sm"
variant="outline"
class="mt-3"
onclick={removeCsd}
disabled={uploadingCsd}
>
<Trash2 class="mr-1 h-4 w-4" /> Quitar certificado
</Button>
{/if}
</div>
{/if}
{#if canEdit}
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={uploadCsd}>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Certificado (.cer) *</span>
<input
type="file"
accept=".cer"
class={inputCls}
required
onchange={(e) => (cerFile = e.currentTarget.files?.[0] ?? null)}
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Llave privada (.key) *</span>
<input
type="file"
accept=".key"
class={inputCls}
required
onchange={(e) => (keyFile = e.currentTarget.files?.[0] ?? null)}
/>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Contraseña de la llave privada *</span>
<input
type="password"
class={inputCls}
bind:value={csdPassword}
required
autocomplete="off"
/>
<span class="text-xs text-muted-foreground">
Se guarda cifrada y no se vuelve a mostrar. Al subir, se verifica que la llave
corresponda al certificado antes de guardar nada.
</span>
</label>
<p class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900 sm:col-span-2 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>
Usa el <strong>CSD</strong>, 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.
</span>
</p>
<div class="flex justify-end sm:col-span-2">
<Button type="submit" disabled={uploadingCsd || !cerFile || !keyFile || !companyId}>
<Upload class="mr-1 h-4 w-4" />
{uploadingCsd ? 'Verificando…' : issuer?.has_csd ? 'Reemplazar certificado' : 'Cargar certificado'}
</Button>
</div>
</form>
{:else if !issuer?.has_csd}
<p class="py-4 text-sm text-muted-foreground">
Esta empresa no tiene certificado cargado. Se requiere el permiso de edición para
subirlo.
</p>
{/if}
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>