feat(fin): timbrado de CFDI 4.0 de ingreso con Comercio Digital
Cierra el ciclo de la factura: construcción del comprobante, sellado con el CSD de la empresa emisora y transmisión al PAC. - cfdi_builder: XML 4.0 de ingreso en el orden de atributos del XSD, del que depende la cadena original y con ella el sello. Todo el dinero con Decimal. - sealer: cadena original vía el XSLT oficial del SAT y firma con la llave del CSD. - pac_comercio_digital: cliente de timbrarV5. Conserva el código y el saldo de folios que el legado leía en una variable que descartaba (CFDI.cs:19324-19336). - csd_service y core/crypto: CSD por empresa, con la contraseña cifrada en la base. Antes el certificado había que dejarlo a mano en el almacenamiento y su contraseña era una variable de entorno global, lo que no funciona con varias empresas emisoras. - Cada intento —también los rechazados— guarda el XML que se transmitió y el que contestó el PAC: sin ese par no hay forma de reconstruir un rechazo cuando termina la petición. La declaración XML se escribe a mano con comillas dobles. lxml la emite con comillas simples, que es XML válido, pero Comercio Digital compara la cadena literal version="1.0" y responde 642 "la versión del XML no es 1.0". El modo (pruebas o producción) sale de invoices.stamping_mode y no se puede pasar por la API: es lo único que separa un timbre de prueba de un CFDI con validez fiscal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
24
.env.example
24
.env.example
@@ -112,3 +112,27 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||
SPOKE_URLS=""
|
||||
|
||||
# ==================================
|
||||
# PAC COMERCIO DIGITAL (timbrado CFDI)
|
||||
# ==================================
|
||||
# El modo de timbrado se decide POR FACTURA, en fin.invoices.stamping_mode.
|
||||
# Esta variable solo fija con qué valor nacen las facturas que no lo especifican.
|
||||
# pruebas -> pruebas.comercio-digital.mx | produccion -> ws.comercio-digital.mx
|
||||
# Una factura en "produccion" emite un CFDI con validez fiscal real ante el SAT.
|
||||
PAC_DEFAULT_MODE=pruebas
|
||||
# Credenciales del web service (headers usrws / pwdws). NO commitear valores reales:
|
||||
# van en .env, que está en .gitignore.
|
||||
PAC_USER=
|
||||
PAC_PASSWORD=
|
||||
# Opcional: correo al que el PAC notifica el comprobante (header email).
|
||||
PAC_NOTIFICATION_EMAIL=
|
||||
# Clave maestra que cifra las contraseñas de los CSD en la base de datos. OBLIGATORIA para
|
||||
# poder cargar certificados desde Configuración de Facturación. Generarla con:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Si se cambia, las contraseñas ya guardadas dejan de poder descifrarse y hay que recargar
|
||||
# los certificados.
|
||||
CSD_ENCRYPTION_KEY=
|
||||
# LEGADO: contraseña global del CSD. Sólo se usa como respaldo si una empresa no tiene la
|
||||
# suya guardada. Lo correcto es cargar el CSD por empresa desde la interfaz.
|
||||
CSD_PASSWORD=
|
||||
|
||||
@@ -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")
|
||||
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal file
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal 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")
|
||||
@@ -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")
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
@@ -92,6 +93,9 @@ class InvoiceBase(BaseModel):
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
# Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
|
||||
# ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
|
||||
stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
|
||||
|
||||
|
||||
class InvoiceCreate(InvoiceBase):
|
||||
@@ -114,6 +118,7 @@ class InvoiceUpdate(BaseModel):
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
stamping_mode: Literal["pruebas", "produccion"] | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(InvoiceBase):
|
||||
@@ -139,3 +144,26 @@ class InvoiceResponse(InvoiceBase):
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InvoiceItemTaxInput(BaseModel):
|
||||
"""Alta o ajuste de un impuesto de la partida.
|
||||
|
||||
El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
|
||||
pueda quedar un desglose que no cuadre con el importe del concepto.
|
||||
"""
|
||||
|
||||
tax_id: int
|
||||
rate: Decimal = Field(..., ge=0, le=1, max_digits=8, decimal_places=6)
|
||||
is_withholding: bool = False
|
||||
|
||||
|
||||
class InvoiceItemTaxResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_item_id: int
|
||||
tax_id: int
|
||||
is_withholding: bool
|
||||
rate: Decimal | None = None
|
||||
amount: Decimal
|
||||
|
||||
@@ -73,6 +73,13 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
|
||||
)
|
||||
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
# ----- Modo de timbrado (por factura, no por entorno) -----
|
||||
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
|
||||
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
|
||||
# cambiarlo después falsearía el registro de con qué intención se emitió.
|
||||
stamping_mode: Mapped[str] = mapped_column(
|
||||
String(12), nullable=False, server_default=text("'pruebas'")
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ from .dto import (
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
)
|
||||
from . import taxes_service
|
||||
from .models import Invoice, InvoiceItem, Payment
|
||||
from .pdf import build_invoice_pdf
|
||||
|
||||
@@ -108,16 +109,43 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
_reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(obj, f, v)
|
||||
obj.updated_by = user_id
|
||||
db.flush()
|
||||
if "tax_rate" in data:
|
||||
# El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
|
||||
taxes_service.sync_invoice_taxes(db, obj)
|
||||
_recompute(db, obj) # tax_rate pudo cambiar
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
|
||||
"""El modo de timbrado es inmutable una vez que la factura tiene timbre.
|
||||
|
||||
Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
|
||||
CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
|
||||
hecho no se edita.
|
||||
"""
|
||||
nuevo = data.get("stamping_mode")
|
||||
if nuevo is None or nuevo == obj.stamping_mode:
|
||||
return
|
||||
# Import diferido: stamping importa invoices, y al revés sería circular.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
if get_stamp(db, obj.id, tenant_id, company_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"La factura ya está timbrada: el modo de timbrado no se puede cambiar "
|
||||
f"(sigue en {obj.stamping_mode!r})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
@@ -378,6 +406,7 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
|
||||
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
@@ -394,7 +423,9 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(item, f, v)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
||||
invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
201
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal file
201
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal 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()
|
||||
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal file
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""
|
||||
382
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal file
382
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal 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)
|
||||
31
backend/api/v1/modules/fin/stamping/dto.py
Normal file
31
backend/api/v1/modules/fin/stamping/dto.py
Normal 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
|
||||
93
backend/api/v1/modules/fin/stamping/models.py
Normal file
93
backend/api/v1/modules/fin/stamping/models.py
Normal 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)
|
||||
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal file
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal 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)
|
||||
95
backend/api/v1/modules/fin/stamping/routes.py
Normal file
95
backend/api/v1/modules/fin/stamping/routes.py
Normal 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}
|
||||
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal file
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal 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")
|
||||
533
backend/api/v1/modules/fin/stamping/service.py
Normal file
533
backend/api/v1/modules/fin/stamping/service.py
Normal 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)
|
||||
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal file
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal 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.
|
||||
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal file
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal 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>
|
||||
@@ -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"/>
|
||||
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal file
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal 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>
|
||||
@@ -103,6 +103,24 @@ class Settings(BaseSettings):
|
||||
S3_FILE_STORAGE: bool = True
|
||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||
|
||||
# ----- PAC Comercio Digital (timbrado CFDI) -----
|
||||
# El modo NO se configura aquí: vive en fin.invoices.stamping_mode, por factura. Esta
|
||||
# variable sólo define con qué valor nacen las facturas que no lo especifican.
|
||||
PAC_DEFAULT_MODE: Literal["pruebas", "produccion"] = "pruebas"
|
||||
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
|
||||
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
|
||||
PAC_USER: str = "" # header usrws
|
||||
PAC_PASSWORD: str = "" # header pwdws
|
||||
PAC_TIMEOUT_SECONDS: int = 15
|
||||
PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
|
||||
# Clave maestra que cifra las contraseñas de los CSD guardadas en la base (ver core/crypto).
|
||||
# Sin ella no se pueden cargar certificados. Se genera con:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CSD_ENCRYPTION_KEY: str = ""
|
||||
# Contraseña global del CSD. LEGADO: sólo se usa como respaldo si una empresa no tiene su
|
||||
# propia contraseña guardada. Lo correcto es cargar el CSD por empresa.
|
||||
CSD_PASSWORD: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=[".env", "../.env"],
|
||||
case_sensitive=True,
|
||||
|
||||
77
backend/core/crypto.py
Normal file
77
backend/core/crypto.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@ import api.v1.modules.fin.catalogs.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.concepts.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.issuer.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.stamping.models # noqa: E402,F401
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
|
||||
|
||||
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}
|
||||
|
||||
141
backend/tests/test_fin_csd.py
Normal file
141
backend/tests/test_fin_csd.py
Normal 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
|
||||
547
backend/tests/test_fin_stamping.py
Normal file
547
backend/tests/test_fin_stamping.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
694
docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
Normal file
694
docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
Normal 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 | 6–7 h de trabajo efectivo |
|
||||
| Riesgo | Medio-bajo — módulo nuevo, sin migraciones a prod, sin tocar flujo de facturación existente |
|
||||
|
||||
> **Operación autónoma.** Este plan se ejecuta en una ventana nocturna sin supervisión. El mecanismo
|
||||
> (corredor, preflight, instrucción, Sunrise) está montado en [`automatizacion/`](../../automatizacion/)
|
||||
> y se describe en §10. **La instrucción que recibe el agente cada ciclo es
|
||||
> `automatizacion/instruccion-nocturna.md`**, que remite a este documento como especificación.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Agregar al backend del CRM la capacidad de **generar, sellar y timbrar un CFDI 4.0 de tipo
|
||||
ingreso (`TipoDeComprobante = "I"`)** a partir de una factura existente en `fin.invoices`,
|
||||
usando el PAC **Comercio Digital** en su **entorno de pruebas**.
|
||||
|
||||
El proceso replica el que hoy vive en la solución legada WinForms `CFDI.sln`
|
||||
(.NET Framework 4.8, `CFDI/CFDI.cs`), pero reimplementado en Python/FastAPI siguiendo las
|
||||
convenciones del CRM. La solución legada es **fuente de verdad del contrato con el PAC**, no
|
||||
un modelo de arquitectura a copiar.
|
||||
|
||||
### Fuera de alcance (NO hacer)
|
||||
|
||||
- Frontend SvelteKit. Este Sundown es backend puro.
|
||||
- Tipos de comprobante distintos de `I` (egreso `E`, traslado `T`, pago `P`, retenciones).
|
||||
- Complementos (Carta Porte, Comercio Exterior, INE, Notarios, Pagos 2.0).
|
||||
- Cancelación, consulta de estatus, recuperación de XML, consulta de saldo del PAC.
|
||||
- Timbrado contra el entorno de **producción** de Comercio Digital.
|
||||
- Generación del PDF/representación impresa del comprobante timbrado y del código QR.
|
||||
- Migraciones aplicadas a bases de datos productivas.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estado actual del CRM (verificado)
|
||||
|
||||
Ya existe y **se debe reutilizar, no duplicar**:
|
||||
|
||||
| Pieza | Ruta | Qué aporta |
|
||||
|---|---|---|
|
||||
| Catálogos SAT | `backend/api/v1/modules/fin/catalogs/` | Schema `sat`: `tax_regimes`, `taxes`, `payment_forms`, `payment_methods`, `voucher_types`, `units_of_measure`, `products_services`, `tax_objects`. Con `seed_data.py`. |
|
||||
| Datos del emisor | `backend/api/v1/modules/fin/issuer/` | `fin.issuer_settings`: `legal_name`, `rfc`, `tax_regime_id`, `zip_code`. Único vigente por empresa. |
|
||||
| Facturas | `backend/api/v1/modules/fin/invoices/` | `fin.invoices`, `fin.invoice_items`, `fin.invoice_item_taxes`, `fin.payments`. Ya tienen FK a los catálogos SAT (`voucher_type_id`, `payment_form_id`, `payment_method_id`, `expedition_zip_code`, `product_service_id`, `unit_of_measure_id`, `tax_object_id`). |
|
||||
| Conceptos | `backend/api/v1/modules/fin/concepts/` | Catálogo interno de conceptos facturables. |
|
||||
| Claves MinIO | `backend/core/s3_keys.py:288` | `company_certificate_key(tenant_id, company_id, certificate_type, timestamp, file_ext)` → `tenants/{tid}/companies/{cid}/certificates/{tipo}_{ts}.{cer\|key}`. **El CSD ya tiene dónde vivir.** |
|
||||
| Config | `backend/core/config.py` | `Settings` (pydantic-settings). Aquí se agregan las variables del PAC. |
|
||||
| Tests | `backend/tests/` | Convención `test_<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 12–13 caracteres)
|
||||
pwdws : password del web service
|
||||
tipo : "XML"
|
||||
email : opcional, en minúsculas; se omite el header si va vacío
|
||||
|
||||
Body: bytes crudos del XML CFDI **ya sellado**, en UTF-8. NO va en base64,
|
||||
NO va envuelto en SOAP.
|
||||
|
||||
Respuesta:
|
||||
body : XML del CFDI timbrado (con el nodo tfd:TimbreFiscalDigital incorporado)
|
||||
headers : uuid, errmsg, saldo, erremail, codigo
|
||||
```
|
||||
|
||||
Validaciones previas que el legado hace antes de salir a la red (replicarlas):
|
||||
|
||||
| Condición | Código | Mensaje |
|
||||
|---|---|---|
|
||||
| `len(usrws)` fuera de 12–13 | 701 | Usuario/password inválido |
|
||||
| `pwdws` vacío | 702 | Usuario/password inválido |
|
||||
| XML nulo o < 200 bytes | 711 | Contenido XML vacío |
|
||||
| Excepción de red | 833 | Error de transmisión |
|
||||
| HTTP != 200 | 998 | Error HTTP |
|
||||
|
||||
**Criterio de éxito del legado:** el header `errmsg` viene vacío. Si trae contenido, es error
|
||||
(`ComDig_Exception`, `CFDI.cs:16516-16519`).
|
||||
|
||||
### 3.2 Defectos del legado que NO se deben replicar
|
||||
|
||||
Al portar `timbrarV5Xml` hay que corregir, no copiar:
|
||||
|
||||
1. **`codigo` nunca recibe el valor del header `codigo`.** En `CFDI.cs:19331-19336` el valor se
|
||||
lee en la variable local `cod2`, que se descarta. El parámetro de salida `codigo` termina
|
||||
siempre en `999` o `991`. → En Python, el código del PAC **sí** se debe leer y persistir.
|
||||
2. **`saldo` nunca se asigna.** Mismo patrón (`CFDI.cs:19324-19327`): se lee en `cod2` y se
|
||||
pierde. El parámetro de salida queda siempre en `0`. → Leerlo y persistirlo como entero.
|
||||
3. **`GetResponseHeader` devuelve `""` y no `null`** cuando el header no existe, así que la
|
||||
rama `if (... == null) codigo = 991` prácticamente nunca se cumple. → En Python usar
|
||||
presencia real en el diccionario de headers.
|
||||
4. **Los errores se tragan con `MessageBox`** y el flujo continúa. → En el CRM, error del PAC
|
||||
se traduce a excepción de dominio y respuesta HTTP con detalle, nunca a un `except: pass`.
|
||||
|
||||
### 3.3 RFC del proveedor de certificación (`CFDI.cs:16665`)
|
||||
|
||||
| Entorno | RFC |
|
||||
|---|---|
|
||||
| Pruebas | `SPR190613I52` |
|
||||
| Producción | `SCD110105654` |
|
||||
|
||||
Sirve para **verificar** que el `RfcProvCertif` del timbre recibido corresponde al entorno
|
||||
esperado. Si no coincide, es error: se timbró contra el entorno equivocado.
|
||||
|
||||
### 3.4 Cadena original y sello (`CFDI.cs:8295-8402`, `CFDI.cs:8150-8210`)
|
||||
|
||||
- La **cadena original** se obtiene aplicando la transformación XSLT oficial del SAT
|
||||
`cadenaoriginal_4_0.xslt` al XML del comprobante **sin sello**, y decodificando las
|
||||
entidades HTML del resultado.
|
||||
- El **sello** es `RSA` + `SHA256` sobre los bytes UTF-8 de la cadena original, resultado en
|
||||
base64. (El legado usa SHA1 solo para retenciones con otro PAC — para CFDI de ingreso con
|
||||
Comercio Digital es **SHA256**.)
|
||||
- La llave privada `.key` del CSD es **PKCS#8 DER cifrada con contraseña**; el certificado
|
||||
`.cer` es **X.509 DER**.
|
||||
- El atributo `Certificado` del comprobante es el **base64 del DER** del `.cer`.
|
||||
- El atributo `NoCertificado` es el número de serie del certificado del SAT, que viene
|
||||
codificado de forma que sus bytes son directamente los 20 caracteres ASCII del número.
|
||||
|
||||
⚠️ **Lección de seguridad del legado:** el XSLT del SAT hace `xsl:include` de otros archivos.
|
||||
El legado tuvo que introducir un resolver que no descarga recursos externos (ver el bloque
|
||||
comentado en `CFDI.cs:8333-8363`). En el CRM, los XSLT se versionan en el repo y la
|
||||
transformación se ejecuta **sin acceso a red y sin habilitar scripts ni `document()`**.
|
||||
|
||||
### 3.5 Detalles del XML CFDI 4.0 verificados en el legado
|
||||
|
||||
- **Orden de atributos del nodo `cfdi:Comprobante`** (`CFDI.cs:14340-14394`):
|
||||
`Version`, `Serie`, `Folio`, `Fecha`, `Sello`, `FormaPago`, `NoCertificado`, `Certificado`,
|
||||
`CondicionesDePago`, `SubTotal`, `Descuento`, `Moneda`, `TipoCambio`, `Total`,
|
||||
`TipoDeComprobante`, `Exportacion`, `MetodoPago`, `LugarExpedicion`.
|
||||
- `Serie` y `CondicionesDePago` se omiten si vienen vacíos; `Descuento` solo si es > 0;
|
||||
`TipoCambio` solo si la moneda no es MXN.
|
||||
- **`Fecha` en CFDI 4.0 va SIN offset de zona horaria** (`CFDI.cs:12654`: el `-06:00` es
|
||||
exclusivo de CFDI 3.3). Formato `YYYY-MM-DDTHH:MM:SS`.
|
||||
- `Exportacion` es obligatorio en 4.0. Para factura de ingreso nacional: `"01"` (no aplica).
|
||||
- `TipoDeComprobante = "I"` es el caso por defecto en el legado (`CFDI.cs:8966`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Diseño a implementar
|
||||
|
||||
### 4.1 Estructura de archivos
|
||||
|
||||
Módulo nuevo por dominio, siguiendo la convención de `fin/`:
|
||||
|
||||
```
|
||||
backend/api/v1/modules/fin/stamping/
|
||||
├── __init__.py
|
||||
├── models.py # InvoiceStamp → fin.invoice_stamps
|
||||
├── dto.py # Pydantic v2
|
||||
├── routes.py # endpoints
|
||||
├── service.py # orquestación del flujo
|
||||
├── cfdi_builder.py # construcción del XML CFDI 4.0 tipo I
|
||||
├── sealer.py # cadena original (XSLT) + sello RSA-SHA256
|
||||
├── pac_comercio_digital.py # cliente HTTP del PAC
|
||||
└── xslt/ # XSLT oficiales del SAT, versionados
|
||||
├── cadenaoriginal_4_0.xslt
|
||||
└── utilerias.xslt # (y cualquier otro archivo incluido por el anterior)
|
||||
```
|
||||
|
||||
Los XSLT se descargan **una sola vez durante el desarrollo** desde `www.sat.gob.mx` y se
|
||||
commitean. En tiempo de ejecución no se descarga nada.
|
||||
|
||||
### 4.2 Modelo de datos
|
||||
|
||||
#### Campo nuevo en `fin.invoices`
|
||||
|
||||
El modo de timbrado **se decide por factura**, no por entorno:
|
||||
|
||||
```
|
||||
stamping_mode VARCHAR(12) NOT NULL DEFAULT 'pruebas' -- 'pruebas' | 'produccion'
|
||||
```
|
||||
|
||||
- Editable desde el CRUD de facturas (`InvoiceCreate` / `InvoiceUpdate` / `InvoiceResponse`).
|
||||
- Al crear una factura sin el campo, toma el valor de `PAC_DEFAULT_MODE` (§4.5); si tampoco
|
||||
está, `'pruebas'`.
|
||||
- **Inmutable una vez timbrada**: si la factura ya tiene un `invoice_stamp` en estado
|
||||
`timbrado`, un `PATCH` que intente cambiar `stamping_mode` se rechaza con 409. Cambiarlo
|
||||
después del hecho falsearía el registro de con qué intención se emitió.
|
||||
- Validado como enum cerrado en el DTO Pydantic. Un valor distinto de los dos permitidos es
|
||||
error de validación (422), nunca un default silencioso.
|
||||
|
||||
Este campo es la **única** fuente del modo. No se acepta modo ni host por body, query string ni
|
||||
cabecera en el endpoint de timbrado.
|
||||
|
||||
#### Tabla nueva `fin.invoice_stamps`
|
||||
|
||||
Una fila por intento de timbrado (incluidos los fallidos, para trazabilidad). Usa
|
||||
`TenantScopedMixin` + `TimestampMixin` como el resto del módulo.
|
||||
|
||||
Campos mínimos:
|
||||
|
||||
- `id`, `invoice_id` (FK `fin.invoices.id`, indexado)
|
||||
- `mode` — `pruebas` | `produccion`
|
||||
- `status` — `pendiente` | `timbrado` | `error`
|
||||
- `uuid` (36, nullable, indexado)
|
||||
- `stamped_at` (datetime, nullable) — `FechaTimbrado` del TFD
|
||||
- `pac_rfc` (13, nullable) — `RfcProvCertif` recibido
|
||||
- `sat_cert_number` (20, nullable) — `NoCertificadoSAT`
|
||||
- `sat_seal` / `cfd_seal` (Text, nullable) — `SelloSAT` / `SelloCFD`
|
||||
- `pac_code` (int, nullable), `pac_balance` (int, nullable) — headers `codigo` y `saldo`
|
||||
- `error_message` (Text, nullable) — header `errmsg`
|
||||
- `xml_file_key` (512, nullable) — clave MinIO del XML timbrado
|
||||
|
||||
El campo `mode` se copia desde `invoices.stamping_mode` **en el momento del timbrado** y queda
|
||||
congelado en la fila: es el registro de contra qué entorno se transmitió realmente.
|
||||
|
||||
Índice único parcial sobre `uuid` (donde `deleted_at IS NULL`) para impedir UUID duplicado.
|
||||
|
||||
**Migración Alembic** en `backend/alembic/versions/`, siguiendo el estilo de las existentes.
|
||||
Se aplica solo en local/testing. **No tocar producción.**
|
||||
|
||||
### 4.3 Flujo del servicio
|
||||
|
||||
`stamp_invoice(db, invoice_id, tenant_id, company_id, user_id)`:
|
||||
|
||||
1. **Cargar y validar** — la factura existe, pertenece al tenant/company, no está cancelada y
|
||||
**no tiene ya un timbre en estado `timbrado`** (idempotencia: si lo tiene, devolver el
|
||||
existente sin volver a timbrar; timbrar dos veces cuesta folios y genera un CFDI duplicado
|
||||
ante el SAT).
|
||||
2. **Validar completitud fiscal** — emisor configurado (`fin.issuer_settings`), receptor con
|
||||
RFC / razón social / régimen / domicilio fiscal, partidas con `product_service_id`,
|
||||
`unit_of_measure_id` y `tax_object_id`, forma y método de pago. Cada faltante se acumula y
|
||||
se devuelve como lista, no se falla en el primero.
|
||||
3. **Construir el XML** sin sello (`cfdi_builder`).
|
||||
4. **Calcular cadena original** por XSLT y **sellar** con el CSD (`sealer`).
|
||||
5. **Insertar `Sello`, `NoCertificado` y `Certificado`** en el comprobante.
|
||||
6. **Transmitir al PAC** (`pac_comercio_digital.stamp`), con el modo leído de
|
||||
`invoice.stamping_mode` — que determina el host, ver §4.5.
|
||||
7. **Verificar el timbre recibido** — el nodo `tfd:TimbreFiscalDigital` existe y trae `UUID`, y
|
||||
el `RfcProvCertif` corresponde al modo solicitado (§3.3). Si no corresponde, es error grave:
|
||||
se timbró contra un entorno distinto del pedido.
|
||||
8. **Persistir** el registro en `fin.invoice_stamps` y subir el XML timbrado a MinIO.
|
||||
9. **Devolver** el resumen (uuid, fecha, modo, clave del archivo).
|
||||
|
||||
Si algo falla en 6–7: se persiste la fila con `status = "error"`, `pac_code` y `error_message`,
|
||||
y se responde con error HTTP. **No se marca la factura como timbrada.**
|
||||
|
||||
### 4.4 Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
|
||||
GET /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
|
||||
GET /api/v1/fin/invoices/{invoice_id}/stamp/xml-url?company_id={cid}
|
||||
```
|
||||
|
||||
Mismo patrón de dependencias que `invoices/routes.py`: `company_id: int = Query(...)`,
|
||||
`current_user: dict = Depends(get_current_user)`, `db: Session = Depends(get_core_db)`.
|
||||
|
||||
### 4.5 Configuración
|
||||
|
||||
En `backend/core/config.py`, agregar al `Settings`:
|
||||
|
||||
```python
|
||||
# ----- PAC Comercio Digital (timbrado CFDI) -----
|
||||
# El modo NO se configura aquí: vive en invoices.stamping_mode (§4.2). Esta variable
|
||||
# solo define con qué valor nacen las facturas nuevas que no lo especifican.
|
||||
PAC_DEFAULT_MODE: str = "pruebas" # pruebas | produccion
|
||||
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
|
||||
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
|
||||
PAC_USER: str = "" # header usrws
|
||||
PAC_PASSWORD: str = "" # header pwdws
|
||||
PAC_TIMEOUT_SECONDS: int = 15
|
||||
PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
|
||||
CSD_PASSWORD: str = "" # contraseña de la llave .key
|
||||
```
|
||||
|
||||
El andamiaje ya está hecho: el passthrough existe en `docker-compose.yml` (bloque
|
||||
`backend.environment`) y los nombres están documentados en `.env.example`. Los **valores** viven
|
||||
solo en `.env`, que está en `.gitignore` (línea 28). Falta únicamente declarar los campos en
|
||||
`Settings`.
|
||||
|
||||
⚠️ **`docker-compose.yml` no está versionado** (`.gitignore` línea 77): cada quien tiene el suyo.
|
||||
El passthrough ya está aplicado en el compose local de este clon, que es donde corre la ventana
|
||||
nocturna, así que el agente lo hereda. Pero **no intentes commitearlo ni versionarlo**: git lo
|
||||
ignora y forzarlo rompería la convención del proyecto. Si otro entorno necesita estas variables,
|
||||
se documentan en `.env.example` — que sí está versionado — y cada quien las añade a su compose.
|
||||
|
||||
Reglas duras:
|
||||
|
||||
- **Ningún valor real** (usuario, password, RFC, contraseña de CSD) se escribe en el código,
|
||||
en tests, en fixtures ni en `.env.example`. Solo variables de entorno con default vacío.
|
||||
- Si faltan `PAC_USER` o `PAC_PASSWORD`, el servicio falla con mensaje claro en vez de intentar
|
||||
timbrar con cadenas vacías. (El legado ya lo hace con los códigos 701/702 — §3.1.)
|
||||
|
||||
#### Resolución del host — regla única
|
||||
|
||||
El host **se deriva exclusivamente del enum** `invoice.stamping_mode`, en el servidor:
|
||||
|
||||
| `stamping_mode` | Host |
|
||||
|---|---|
|
||||
| `pruebas` | `PAC_HOST_TEST` → `pruebas.comercio-digital.mx` |
|
||||
| `produccion` | `PAC_HOST_PROD` → `ws.comercio-digital.mx` |
|
||||
|
||||
Tres reglas que el cliente del PAC debe cumplir:
|
||||
|
||||
1. **Nunca aceptar un host, una URL ni un modo por parámetro de la petición HTTP.** El modo se
|
||||
lee de la factura y punto. Es el defecto de diseño del legado, donde `CFDIPacUrl` es un
|
||||
campo mutable que cualquier rama del código puede reasignar (§3.1).
|
||||
2. **Fallar de entrada** ante un `stamping_mode` que no sea exactamente `"pruebas"` o
|
||||
`"produccion"`. Nada de default silencioso. Ojo con el legado: en `CFDI.cs:19288` un host
|
||||
vacío cae a `pruebas.comercio-digital.mx` — comportamiento implícito que aquí no se replica.
|
||||
3. **Verificar el timbre recibido** contra el modo solicitado. Si se pidió `pruebas` y el
|
||||
`RfcProvCertif` devuelto es el de producción (`SCD110105654`), o al revés, se trata como
|
||||
error grave: se registra y la operación **no** se da por exitosa.
|
||||
|
||||
⚠️ **Contexto para quien lea esto después.** Las credenciales configuradas son de la cuenta de
|
||||
**producción** del PAC (§8, B1), y por decisión del responsable del proyecto **no** existe un
|
||||
veto de entorno: una factura con `stamping_mode = "produccion"` timbra un CFDI con validez
|
||||
fiscal real ante el SAT desde cualquier entorno donde estén esas credenciales, incluido
|
||||
desarrollo. Deshacerlo requiere cancelar el comprobante ante el SAT. Las tres reglas de arriba
|
||||
son, por tanto, la única barrera técnica que queda: no se relajan sin decisión explícita.
|
||||
|
||||
**Restricción para este Sundown:** todas las facturas que el agente cree o use para pruebas
|
||||
llevan `stamping_mode = "pruebas"`. El agente **no** debe timbrar en `produccion` bajo ninguna
|
||||
circunstancia (ya está en Fuera de alcance, §1).
|
||||
|
||||
### 4.6 Dependencias nuevas — justificación obligatoria
|
||||
|
||||
Dos, ambas necesarias y sin alternativa razonable en la stdlib:
|
||||
|
||||
| Paquete | Por qué |
|
||||
|---|---|
|
||||
| `lxml` | La cadena original del SAT **solo** se puede calcular aplicando el XSLT 1.0 oficial. `xml.etree` de la stdlib no hace XSLT. Es la única implementación de XSLT 1.0 madura en Python. |
|
||||
| `cryptography` | Lectura del `.key` (PKCS#8 DER cifrado) y del `.cer` (X.509 DER), y firma RSA-SHA256. Ya entra de forma transitiva por `python-jose[cryptography]`; aquí se declara explícita porque pasa a ser dependencia directa. |
|
||||
|
||||
Agregar a `backend/requirements.txt` con versión fijada, en una sección comentada
|
||||
`# CFDI / timbrado`, respetando el estilo del archivo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pruebas
|
||||
|
||||
### 5.1 Unitarias — sin red (`backend/tests/test_fin_stamping.py`)
|
||||
|
||||
- **Constructor de XML**: dada una factura de prueba, el XML resultante valida contra el XSD
|
||||
`cfdv40.xsd`, tiene los atributos en el orden correcto, `TipoDeComprobante="I"`,
|
||||
`Exportacion="01"`, y `Fecha` sin offset de zona horaria.
|
||||
- **Omisión de atributos opcionales**: sin serie → no aparece `Serie`; descuento en 0 → no
|
||||
aparece `Descuento`; moneda MXN → no aparece `TipoCambio`.
|
||||
- **Cadena original**: la transformación XSLT produce una cadena que empieza con `||` y
|
||||
termina con `||`, y no intenta ninguna descarga externa.
|
||||
- **Sello**: firmando con el CSD público de pruebas del SAT, el sello resultante **verifica**
|
||||
contra la llave pública del `.cer`. Es la prueba fuerte del sellado.
|
||||
- **Cliente del PAC contra un doble**: valida los cinco casos de error previos (701, 702, 711,
|
||||
833, 998) y el camino feliz, comprobando que `codigo` y `saldo` **sí** se extraen de los
|
||||
headers — el defecto corregido de la sección 3.2.
|
||||
- **Idempotencia**: timbrar dos veces la misma factura no genera un segundo llamado al PAC.
|
||||
- **Validación de completitud**: una factura incompleta devuelve **todos** los faltantes.
|
||||
- **Resolución del host por modo** (§4.5): una factura con `stamping_mode = "pruebas"` produce
|
||||
una URL contra `pruebas.comercio-digital.mx`, y una con `"produccion"` contra
|
||||
`ws.comercio-digital.mx`. Ambos casos se prueban contra el doble del PAC, sin red.
|
||||
- **Modo inválido**: `stamping_mode = "prod"`, `""` o `None` es error de validación, y en
|
||||
ningún caso termina resolviendo a un host.
|
||||
- **El modo no se puede inyectar por HTTP**: mandar `mode` / `host` / `url` en el body o el
|
||||
query string de `POST /stamp` no altera el host usado. Es la prueba de la regla 1 de §4.5.
|
||||
- **`stamping_mode` inmutable tras timbrar**: `PATCH` sobre una factura ya timbrada devuelve
|
||||
409 y no modifica el valor.
|
||||
- **Verificación de `RfcProvCertif`**: un doble que responde con el RFC del entorno equivocado
|
||||
deja la fila en `status = "error"` y no marca la factura como timbrada.
|
||||
|
||||
### 5.2 Integración — timbrado real en pruebas (`test_fin_stamping_pac.py`)
|
||||
|
||||
Marcada con `@pytest.mark.skipif` sobre la ausencia de `PAC_USER`/`PAC_PASSWORD` en el entorno,
|
||||
para que la suite normal siga siendo verde sin credenciales.
|
||||
|
||||
- Timbra un CFDI de ingreso completo contra `pruebas.comercio-digital.mx`.
|
||||
- Verifica: `errmsg` vacío, `uuid` con formato UUID válido, `RfcProvCertif == "SPR190613I52"`,
|
||||
el XML devuelto contiene `tfd:TimbreFiscalDigital` y la fila en `fin.invoice_stamps` queda en
|
||||
`status = "timbrado"`.
|
||||
|
||||
### 5.3 CSD de pruebas
|
||||
|
||||
Se usa el **CSD público de demostración que publica el SAT** (RFC de prueba). No es dato
|
||||
sensible y puede vivir en `backend/tests/fixtures/csd/`. **Bajo ninguna circunstancia** se
|
||||
commitea un CSD real de un cliente.
|
||||
|
||||
Datos dummy autorizados para el resto de las pruebas: RFC `XAXX010101000`,
|
||||
pedimento `0000-0000000`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Criterios de aceptación
|
||||
|
||||
Verificables uno por uno en el Sunrise:
|
||||
|
||||
1. `POST /api/v1/fin/invoices/{id}/stamp` sobre una factura completa con
|
||||
`stamping_mode = "pruebas"` devuelve 200 con `uuid`, `stamped_at`, `mode: "pruebas"` y
|
||||
`xml_file_key`.
|
||||
2. El XML timbrado queda en MinIO bajo una clave obtenida de `core/s3_keys.py` (función nueva,
|
||||
no ruta construida a mano en la ruta HTTP).
|
||||
3. Existe una fila en `fin.invoice_stamps` con `status = "timbrado"` y `pac_rfc = "SPR190613I52"`.
|
||||
4. Un segundo `POST` sobre la misma factura devuelve el timbre existente **sin** llamar al PAC.
|
||||
5. Una factura sin `issuer_settings` o con partidas sin clave de producto/servicio devuelve 422
|
||||
con la lista completa de faltantes.
|
||||
6. Un error del PAC deja fila con `status = "error"`, `pac_code` y `error_message` poblados, y
|
||||
la factura **no** queda marcada como timbrada.
|
||||
7. `pytest backend/tests/test_fin_stamping.py` pasa completo sin acceso a red.
|
||||
8. `black`, `flake8` y `mypy` pasan sobre el módulo nuevo.
|
||||
9. La migración Alembic sube y baja limpio (`upgrade head` / `downgrade -1`) en local, e
|
||||
incluye tanto `fin.invoice_stamps` como la columna `fin.invoices.stamping_mode`.
|
||||
10. No hay credenciales, RFC reales ni CSD de cliente en el diff. Verificable con
|
||||
`git diff --stat` + inspección de los archivos nuevos.
|
||||
11. `stamping_mode` es editable vía `POST`/`PATCH` de facturas, se refleja en
|
||||
`InvoiceResponse`, y una factura ya timbrada rechaza su modificación con 409.
|
||||
12. Con `stamping_mode = "produccion"` el cliente resuelve `ws.comercio-digital.mx` — probado
|
||||
**solo contra el doble del PAC**. Ninguna prueba transmite a producción.
|
||||
|
||||
---
|
||||
|
||||
## 7. Convenciones obligatorias
|
||||
|
||||
- **Nombres de código en inglés**; comentarios y docstrings en **español** para la lógica
|
||||
fiscal/aduanera, igual que en `fin/issuer/models.py` y `fin/catalogs/models.py`.
|
||||
- **FastAPI**: Pydantic v2, routers por dominio, DTOs separados de los modelos.
|
||||
- **Sin `print` de depuración. Sin `except: pass`.** Todo error se maneja o se propaga.
|
||||
- Conventional commits (`feat(fin): ...`, `test(fin): ...`, `chore(deps): ...`).
|
||||
- Rama `feature/AS-XXX-timbrado-cfdi-ingreso`. **PR, nunca push directo** a main/master/
|
||||
develop/release.
|
||||
- No inventar tablas, campos ni endpoints fuera de los especificados aquí. Si hace falta algo
|
||||
que no está descrito, se documenta como PENDIENTE DECISIÓN.
|
||||
|
||||
### Ambigüedad durante la ejecución autónoma
|
||||
|
||||
**No adivinar.** Cualquier decisión no cubierta por este documento se implementa con la opción
|
||||
más conservadora y se documenta en el cuerpo del PR bajo un encabezado
|
||||
`## PENDIENTE DECISIÓN`, con: qué se asumió, qué alternativas había, y qué se necesita para
|
||||
resolverlo. Es la misma convención que ya usa `fin/catalogs/seed_data.py`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Bloqueantes a resolver ANTES de disparar
|
||||
|
||||
El DoR **no se cumple** hasta que estos tres estén cerrados:
|
||||
|
||||
- **B1 — Credenciales del PAC. RESUELTO, con reserva.** Se reutilizan las credenciales de
|
||||
Comercio Digital que hoy usa la solución legada, apuntando al host de pruebas. Van en `.env`
|
||||
como `PAC_USER` / `PAC_PASSWORD`; el andamiaje ya está listo (§4.5). **No se pegan en este
|
||||
documento, en el chat ni en ningún archivo versionado.**
|
||||
→ Reserva: son credenciales de **producción** — en el legado las ramas de pruebas y
|
||||
producción del ternario son idénticas (§9). Mientras eso siga así, la única barrera entre un
|
||||
timbre de prueba y un CFDI fiscal real es el host, y por eso la salvaguarda de §4.5 no es
|
||||
opcional. Conviene solicitar a Comercio Digital un usuario de pruebas independiente y
|
||||
sustituirlo en `.env` cuando exista; no bloquea este Sundown.
|
||||
→ Nota: el `usrws` es de 12–13 caracteres (formato de RFC), así que el RFC del emisor en el
|
||||
CFDI de prueba debe ser coherente con la cuenta del PAC. Si el CSD de demostración del SAT
|
||||
resulta incompatible con esa cuenta, el agente lo documenta como PENDIENTE DECISIÓN en el PR
|
||||
en vez de inventar un RFC.
|
||||
|
||||
- **B2 — Trabajo previo sin commitear.** `backend/api/v1/modules/fin/catalogs/` aparece como
|
||||
no rastreado en git. Este ticket **depende** de esos catálogos. Hay que commitearlo (o
|
||||
fusionarlo) antes de disparar; si no, el agente arranca sobre una base que no está en el
|
||||
árbol y el PR saldrá mezclando dos trabajos.
|
||||
|
||||
- **B3 — Rama base del PR.** El repo tiene `feature/crm-cumplimiento-pdf` como rama principal
|
||||
de integración, no `main`. Confirmar que el PR va contra ella.
|
||||
|
||||
- **B4 — CLI de Claude Code. RESUELTO.** Instalado en WSL (`~/.local/bin/claude`, versión
|
||||
`2.1.224`) y verificado también por la ruta que usa el corredor (PowerShell → `wsl.exe` →
|
||||
`bash -lc`). El ensayo de extremo a extremo corre y devuelve `ENSAYO-OK` — ver §10.3.
|
||||
|
||||
- **B5 — La tarea programada no está registrada.** Requiere PowerShell **como administrador**; un
|
||||
agente no puede ni debe registrarse una tarea del sistema. El bloque listo para pegar está en
|
||||
§10.6. Al registrarla, verificar que `NextRunTime` **no** salga vacío: una tarea con hora de
|
||||
arranque ya pasada nunca dispara y en la interfaz se ve perfectamente bien.
|
||||
|
||||
> **Alcance de la noche: COMPLETO, en una sola ventana.** Se planteó partirlo en dos —el mecanismo
|
||||
> primero, el timbrado real después—, siguiendo la recomendación del kit de que la primera ventana
|
||||
> lleve un entregable pequeño. **Decisión del responsable del proyecto: se ejecutan los cinco
|
||||
> entregables de §4 en la ventana del lunes 10.** No se reserva nada para una segunda noche.
|
||||
>
|
||||
> Consecuencia práctica para el agente: la escalera de `instruccion-nocturna.md` se recorre entera y
|
||||
> el entregable 5 (timbrado real contra `pruebas.comercio-digital.mx`) **sí** entra en el alcance,
|
||||
> siempre que `PAC_USER` y `PAC_PASSWORD` estén en el entorno. Si faltan, se salta ese entregable y
|
||||
> se sigue con la escalera — no es motivo para detener la noche.
|
||||
|
||||
---
|
||||
|
||||
## 9. Hallazgo de seguridad — acción separada de este ticket
|
||||
|
||||
Durante el análisis del legado se encontraron **credenciales de ambos PAC en texto plano dentro
|
||||
del código fuente** de `CFDI.cs`. Ubicaciones exactas:
|
||||
|
||||
| Líneas | PAC | Contenido |
|
||||
|---|---|---|
|
||||
| 2709, 2711 | Edicom | usuario y password (2710: password anterior, comentado) |
|
||||
| 2720, 2721 | Comercio Digital | usuario y password |
|
||||
| 6858, 6860 | Edicom | duplicado exacto del bloque 2709 |
|
||||
| 6863, 6864 | Comercio Digital | duplicado exacto del bloque 2720 |
|
||||
| 7210, 7212 | Edicom | tercer duplicado |
|
||||
| 7216, 7217 | Comercio Digital | tercer duplicado |
|
||||
| 21061, 21063 | Edicom | dentro de `tst_xml_ine()` (línea 20910), más un `Emisor_Rfc` fijo |
|
||||
| 20833, 20834 | Edicom | dentro de `tst_xml()` (línea 20695), comentado |
|
||||
|
||||
Agravantes:
|
||||
|
||||
1. **El "modo pruebas" de Comercio Digital usa credenciales de producción.** Los ternarios de
|
||||
las líneas 2720-2721 (y sus dos duplicados) tienen **ambas ramas idénticas**:
|
||||
`CFDIMod == "Prueba" ? X : X`. Lo único que realmente cambia entre entornos es la URL
|
||||
(línea 2722).
|
||||
2. **`tst_xml_ine()` no se invoca desde ningún lado, pero se compila.** Las credenciales quedan
|
||||
en el ejecutable distribuido, y `Costura.Fody` embebe todo en un único `.exe`: son
|
||||
recuperables con cualquier decompilador de .NET.
|
||||
3. El password de Edicom de la línea 2711 tiene forma de contraseña corporativa, con riesgo de
|
||||
estar reutilizada en otros sistemas.
|
||||
|
||||
Todo está commiteado en el repositorio `CFDI` (rama `main`, con remoto configurado).
|
||||
|
||||
**Recomendación:** rotar esas credenciales con Comercio Digital y moverlas a configuración
|
||||
externa en la solución legada. Es un ticket aparte — **no** forma parte de este Sundown y el
|
||||
agente autónomo **no** debe tocar la solución C#.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operación de la ventana nocturna
|
||||
|
||||
El plan se ejecuta sin supervisión entre las 17:00 del lunes 10 y las 07:00 del martes 11 de agosto
|
||||
de 2026, en ciclos de 30 minutos disparados por el Programador de tareas de Windows. Cada ciclo es
|
||||
un proceso nuevo **sin memoria del anterior**: la única continuidad es la bitácora.
|
||||
|
||||
### 10.1 Las piezas
|
||||
|
||||
| Archivo | Dónde corre | Qué hace |
|
||||
|---|---|---|
|
||||
| `automatizacion/ventana-nocturna.ps1` | Windows | El corredor. Vigila la ventana, el candado, el árbol de procesos y la cuota |
|
||||
| `automatizacion/ciclo-noche.sh` / `ciclo-ensayo.sh` | WSL | Envoltorios **sin argumentos**. Existen para que ningún argumento lleve espacios — ver §10.2 |
|
||||
| `automatizacion/ciclo-wsl.sh` | WSL | Un ciclo: mete la instrucción por stdin y lanza el agente |
|
||||
| `automatizacion/cpu_arbol.py` | WSL | Mide si el ciclo trabaja o está muerto de pie |
|
||||
| `automatizacion/instruccion-nocturna.md` | — | **La instrucción del agente.** Es el 80% del valor del montaje |
|
||||
| `automatizacion/instruccion-ensayo.txt` | — | Instrucción de una línea para el ensayo del mecanismo |
|
||||
| `automatizacion/preflight.py` | WSL | Comprueba, antes de irse, que la ventana *puede* funcionar |
|
||||
| `automatizacion/SUNRISE.md` | — | Qué revisar el martes por la mañana, en orden |
|
||||
|
||||
Nada de esto se commitea desde un ciclo nocturno: la bitácora vive en `~/.ventana/`, y el log del
|
||||
corredor en `%LOCALAPPDATA%\ventana-nocturna\`, ambos fuera del árbol versionado.
|
||||
|
||||
### 10.2 La adaptación a WSL, y por qué no es cosmética
|
||||
|
||||
El kit de operación nocturna asume Windows de punta a punta. Aquí el proyecto vive en WSL2, así que
|
||||
el corredor sigue en Windows pero cada ciclo entra a la distro con `wsl.exe`. Tres consecuencias que
|
||||
**no se deben "simplificar"**:
|
||||
|
||||
1. **La CPU se mide dentro de Linux, no desde Windows.** En WSL2 el trabajo ocurre en una VM ligera:
|
||||
`Get-Process` y `Win32_Process` solo ven `wsl.exe`, un cliente delgado con CPU casi nula. Medir
|
||||
desde Windows daría "mudo" en **todos** los ciclos, incluidos los sanos, y el corte por silencio
|
||||
apagaría la ventana en el primer disparo. Verificado: un árbol que trabaja de verdad mide 8.00 s
|
||||
con `cpu_arbol.py` y **0.00 s** si solo se mira la raíz.
|
||||
2. **Matar `wsl.exe` no mata al agente.** El árbol hay que terminarlo también del lado Linux
|
||||
(`pkill`), que es el que consume cuota.
|
||||
3. **`bash -lc`, nunca `bash -c`.** El CLI vive en `~/.local/bin`, que solo entra al `PATH` en un
|
||||
shell de *login*. Sin el `-l`, el ciclo muere con «command not found» y cero caracteres de salida
|
||||
— indistinguible de un cuelgue. `ciclo-wsl.sh` además asegura ese `PATH` por su cuenta, para que
|
||||
un ensayo lanzado a mano se comporte igual que el ciclo real.
|
||||
4. **Ningún argumento puede contener espacios**, y por eso existen `ciclo-noche.sh` y
|
||||
`ciclo-ensayo.sh`. `Start-Process -ArgumentList` no entrecomilla: el elemento
|
||||
`"bash '<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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
};
|
||||
|
||||
88
frontend/src/lib/api/fin/stamping.ts
Normal file
88
frontend/src/lib/api/fin/stamping.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user