diff --git a/backend/alembic/versions/k6l7m8n9o0p1_sat_tax_regimes_2024.py b/backend/alembic/versions/k6l7m8n9o0p1_sat_tax_regimes_2024.py new file mode 100644 index 0000000..1cc1efd --- /dev/null +++ b/backend/alembic/versions/k6l7m8n9o0p1_sat_tax_regimes_2024.py @@ -0,0 +1,49 @@ +"""Claves de c_RegimenFiscal con vigencia 2024: 628, 629 y 630. + +El catálogo se había sembrado con las 19 claves vigentes hasta 2022 y quedaron fuera las +tres que el SAT publicó con vigencia a partir del 01-01-2024 (628 Hidrocarburos, 629 +Regímenes Fiscales Preferentes y Empresas Multinacionales, 630 Enajenación de acciones en +bolsa de valores). Sin ellas, el select de régimen del receptor no puede representar a un +contribuyente en esos regímenes y el timbrado quedaría con una clave incorrecta. + +``sync_catalogs`` es idempotente y hace upsert: inserta las tres claves nuevas y refresca +descripción y banderas de las que ya existen, sin tocar el resto de los catálogos. + +No se agrega la clave 609 (Consolidación): su vigencia terminó el 31-12-2019 y el catálogo +solo lleva claves vigentes. + +Revision ID: k6l7m8n9o0p1 +Revises: j5k6l7m8n9o0 +Create Date: 2026-08-11 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs + +revision: str = "k6l7m8n9o0p1" +down_revision: Union[str, None] = "j5k6l7m8n9o0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NUEVAS = ("628", "629", "630") + + +def upgrade() -> None: + sync_catalogs(op.get_bind()) + + +def downgrade() -> None: + # No se borran las filas: ``crm.accounts.tax_regime_id`` y + # ``fin.issuer_settings.tax_regime_id`` las referencian por FK, y un CFDI ya timbrado + # con una de estas claves debe seguir siendo legible. Se desactivan, que es el mismo + # criterio que usa el catálogo para una clave retirada por el SAT. + op.execute( + sa.text( + "UPDATE sat.tax_regimes SET is_active = false, updated_at = now() " + "WHERE code IN :codes" + ).bindparams(sa.bindparam("codes", value=_NUEVAS, expanding=True)) + ) diff --git a/backend/api/v1/modules/crm/expediente_gateway/models.py b/backend/api/v1/modules/crm/expediente_gateway/models.py index bf6fa15..098de2e 100644 --- a/backend/api/v1/modules/crm/expediente_gateway/models.py +++ b/backend/api/v1/modules/crm/expediente_gateway/models.py @@ -25,6 +25,12 @@ KIND_COMPLETAR = "completar" # Tipos del outbox de ARCHIVOS (efc_file_outbox). FILE_KIND_DOCUMENTO = "documento" +# Los dos XML del timbrado. Son kinds SEPARADOS y no un solo 'cfdi', porque la guarda de +# idempotencia es (source_table, source_id, kind): los dos XML de un mismo timbre comparten +# source_id —el id del intento—, así que con un kind común la entrega del segundo se saltaría +# para siempre en cuanto el primero quedara 'sent'. +FILE_KIND_CFDI_REQUEST = "cfdi_request" +FILE_KIND_CFDI_RESPONSE = "cfdi_response" # Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias # independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y @@ -32,6 +38,9 @@ FILE_KIND_DOCUMENTO = "documento" SOURCE_CRM_DOCUMENTS = "crm.documents" SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents" SOURCE_FIN_INVOICES = "fin.invoices" +# El origen de los XML del timbrado es el INTENTO (fin.invoice_stamps), no la factura: una +# factura puede acumular varios intentos y el par enviado/recibido pertenece a uno concreto. +SOURCE_FIN_INVOICE_STAMPS = "fin.invoice_stamps" # Estados (columna status). STATUS_PENDING = "pending" diff --git a/backend/api/v1/modules/crm/expediente_gateway/service.py b/backend/api/v1/modules/crm/expediente_gateway/service.py index e3b8b93..6eabe61 100644 --- a/backend/api/v1/modules/crm/expediente_gateway/service.py +++ b/backend/api/v1/modules/crm/expediente_gateway/service.py @@ -420,6 +420,19 @@ def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> ) +def dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None: + """Despacha la entrega de una fila ya COMMITEADA del outbox de archivos. + + Está separado de ``enqueue_file_best_effort`` porque esa función no commitea: la fila viaja en + la transacción de quien la origina, y despachar antes del commit haría que el worker buscara + una fila que todavía no existe. El orden es siempre encolar → commit → despachar. + + No despachar no pierde nada: ``sweep_file_outbox`` recoge lo que quede en ``pending``. Esto + solo acelera la entrega del caso normal. + """ + _dispatch_file_delivery(outbox_id, tenant_id, company_id) + + def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None: """Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia. diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 7968a27..e67a2a9 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -17,6 +17,26 @@ router = APIRouter() MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+") +# Extensiones que el CRM acepta subir. Es una ALLOWLIST y no una lista de vetados: lo segundo +# deja pasar todo lo que nadie pensó en prohibir. +EXTENSIONES_PERMITIDAS = frozenset({ + # Documentos + "pdf", "xml", "csv", "txt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", + # Imágenes (fotos de maniobras, sellos, evidencias) + "jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", + # Paquetes (juegos de documentos de un embarque) + "zip", "rar", "7z", + # Correo, que en comercio exterior se archiva como evidencia + "msg", "eml", +}) + +# Prefijos —ya dentro de ``tenants/{tid}/companies/{cid}/``— que son documentos del CRM. El +# alcance de estos endpoints es «los archivos que el CRM subió», NO todo el almacén de la +# company: ahí conviven los certificados de la FIEL y del CSD, los CFDI, los CSV de importación +# y el branding, que nada tienen que ver con el permiso de módulo ``crm.access``. +_PREFIJOS_DOCUMENTOS = ("crm-docs/",) +_PATRON_DOCUMENTOS = re.compile(r"^expedientes/\d+/documents/") + def _safe_filename(name: str | None) -> str: base = (name or "archivo").strip().replace(" ", "_") @@ -24,6 +44,41 @@ def _safe_filename(name: str | None) -> str: return base[:120] +def validar_extension(name: str | None) -> None: + """Rechaza lo que no esté en la allowlist. Un archivo sin extensión tampoco pasa. + + Se valida el NOMBRE y no el ``content_type``: el segundo lo pone el navegador y quien sube + el archivo lo controla, así que no es una comprobación. + """ + _, punto, extension = (name or "").rpartition(".") + if not punto or extension.lower() not in EXTENSIONES_PERMITIDAS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Ese tipo de archivo no está permitido.", + ) + + +def _validar_alcance(key: str, tenant_id: int, company_id: int) -> None: + """Comprueba que la key sea un documento del CRM de ESTE tenant y company. + + Son dos guardas y la segunda no reemplaza a la primera. El aislamiento por + tenant/company evita leer el almacén de otro cliente; el alcance por prefijo evita que el + permiso de módulo del CRM sirva para firmar un objeto que pertenece a otro módulo. + """ + prefijo = f"tenants/{tenant_id}/companies/{company_id}/" + if not key.startswith(prefijo): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance" + ) + + relativa = key[len(prefijo):] + if relativa.startswith(_PREFIJOS_DOCUMENTOS) or _PATRON_DOCUMENTOS.match(relativa): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance" + ) + + @router.post("/uploads") async def upload_file( file: UploadFile = File(...), @@ -31,6 +86,7 @@ async def upload_file( current_user: dict = Depends(get_current_user), ): tenant_id = current_user["tenant_id"] + validar_extension(file.filename) content = await file.read() if len(content) > MAX_UPLOAD_BYTES: raise HTTPException( @@ -55,11 +111,7 @@ def get_upload_url( company_id: int = Query(..., description="Company ID"), current_user: dict = Depends(get_current_user), ): - tenant_id = current_user["tenant_id"] - # Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento). - prefix = f"tenants/{tenant_id}/companies/{company_id}/" - if not key.startswith(prefix): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + _validar_alcance(key, current_user["tenant_id"], company_id) return {"url": presigned_get_url(key)} @@ -72,11 +124,12 @@ def download_file( """Transmite el archivo por el backend (sin exponer MinIO al navegador). Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``. + + Mismo alcance que ``/uploads/url``, y por la misma razón: este endpoint entrega los BYTES, + así que dejarlo más abierto que el que solo firma una URL sería la puerta grande al lado de + la que se acaba de cerrar. """ - tenant_id = current_user["tenant_id"] - prefix = f"tenants/{tenant_id}/companies/{company_id}/" - if not key.startswith(prefix): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + _validar_alcance(key, current_user["tenant_id"], company_id) try: data = get_object_bytes(key) except Exception: diff --git a/backend/api/v1/modules/fin/catalogs/seed_data.py b/backend/api/v1/modules/fin/catalogs/seed_data.py index 71beb47..b87c00a 100644 --- a/backend/api/v1/modules/fin/catalogs/seed_data.py +++ b/backend/api/v1/modules/fin/catalogs/seed_data.py @@ -86,6 +86,10 @@ TAX_REGIMES: list[dict] = [ _regime("624", "Coordinados", False, True), _regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False), _regime("626", "Régimen Simplificado de Confianza", True, True), + # Claves publicadas por el SAT con vigencia a partir del 01-01-2024. + _regime("628", "Hidrocarburos", False, True), + _regime("629", "De los Regímenes Fiscales Preferentes y de las Empresas Multinacionales", True, False), + _regime("630", "Enajenación de acciones en bolsa de valores", True, False), ] diff --git a/backend/api/v1/modules/fin/stamping/service.py b/backend/api/v1/modules/fin/stamping/service.py index 8ab6a49..151f978 100644 --- a/backend/api/v1/modules/fin/stamping/service.py +++ b/backend/api/v1/modules/fin/stamping/service.py @@ -44,6 +44,12 @@ logger = logging.getLogger(__name__) CFDI_NS = "http://www.sat.gob.mx/cfd/4" TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital" +# Tipo con el que los XML del timbrado entran al expediente de EFC. Se reusa el de la factura en +# vez de inventar uno: la lista de tipos está duplicada a mano en este repo y en EFC +# (``TIPOS_DOCUMENTO_CRM``), y una clave que solo exista de este lado se rechaza allá. Los tres +# archivos del CFDI —PDF, XML enviado y XML recibido— se distinguen por su nombre de archivo. +_EFC_TIPO_CFDI = "factura_venta" + # -------------------------------------------------------------------------------------- # Lectura @@ -455,11 +461,119 @@ def stamp_invoice( # 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}" + # Las filas del outbox van en ESTA transacción, junto con el timbre: así no puede quedar un + # CFDI timbrado sin su intención de entrega al expediente, ni al revés. + pendientes = _encolar_xml_al_expediente(db, invoice, stamp) + db.commit() db.refresh(stamp) + + # Después del commit: el worker necesita encontrar las filas ya existentes. + _despachar_xml_al_expediente(pendientes, tenant_id, company_id) return stamp +def _encolar_xml_al_expediente(db: Session, invoice: Invoice, stamp: InvoiceStamp) -> list[int]: + """Encola hacia el expediente de EFC el XML transmitido al PAC y el que contestó. + + Devuelve los ids de las filas del outbox, para despacharlas después del commit. + + Se entrega el par del intento que SÍ obtuvo timbre; los rechazados quedan en el CRM y se + consultan por ``/stamp/attempts/{id}/xml-url``. Un expediente fiscal con los comprobantes que + el PAC rechazó no aporta respaldo, solo ruido. + + ``delete_local=False`` a diferencia de los documentos que sube el usuario: el XML timbrado es + el comprobante fiscal y el CRM lo sirve por ``/stamp/xml-url``. El corte directo que borra la + copia local aplica a un documento cuya única razón de existir es vivir en el expediente; aquí + dejaría esos endpoints apuntando a un objeto inexistente. + + Best-effort de punta a punta: si EFC está apagado o el encolado falla, el timbre ya es válido + ante el SAT y no puede caerse por esto. Por eso NUNCA propaga: ``enqueue_file_best_effort`` ya + se protege con un SAVEPOINT, pero todo lo que rodea a la llamada —resolver el expediente, + armar los nombres— también tiene que ser incapaz de tumbar un CFDI ya timbrado. + """ + try: + return _encolar_xml_al_expediente_inner(db, invoice, stamp) + except Exception: # noqa: BLE001 + logger.exception( + "timbrado: falló el encolado de los XML del intento %s hacia EFC; el timbre no se toca", + stamp.id, + ) + return [] + + +def _encolar_xml_al_expediente_inner( + db: Session, invoice: Invoice, stamp: InvoiceStamp +) -> list[int]: + """Cuerpo de ``_encolar_xml_al_expediente``; ver ahí el contrato y el porqué.""" + from ...crm.expediente_gateway import service as gateway # noqa: PLC0415 + from ...crm.expediente_gateway.doc_types import is_valid_doc_type # noqa: PLC0415 + from ...crm.expediente_gateway.models import ( # noqa: PLC0415 + FILE_KIND_CFDI_REQUEST, + FILE_KIND_CFDI_RESPONSE, + SOURCE_FIN_INVOICE_STAMPS, + ) + + # El expediente nace con la oportunidad y se hereda vía ``case_id``. Una factura suelta — + # capturada sin pasar por el ciclo comercial— no tiene a dónde entregar, y eso no es un error. + if not invoice.case_id: + logger.info( + "timbrado: la factura %s no tiene expediente (case_id nulo); no se entregan los XML a EFC", + invoice.id, + ) + return [] + + # El tipo viaja al catálogo GLOBAL de EFC, compartido por todas las organizaciones. Se valida + # contra el set cerrado para no crear ahí un tipo basura que nadie limpia después. + if not is_valid_doc_type(_EFC_TIPO_CFDI): + logger.error( + "timbrado: %r no está en el catálogo de tipos que EFC acepta; no se entregan los XML", + _EFC_TIPO_CFDI, + ) + return [] + + partes = ( + (FILE_KIND_CFDI_REQUEST, stamp.request_xml_file_key, "envio", "CFDIREQ"), + (FILE_KIND_CFDI_RESPONSE, stamp.response_xml_file_key, "respuesta", "CFDIRES"), + ) + + filas: list[int] = [] + for kind, s3_key, sufijo, prefijo_ref in partes: + if not s3_key: + # El almacenamiento falló al guardar el intento: no hay objeto que entregar. + logger.warning( + "timbrado: el intento %s no tiene XML de %s guardado; no se entrega a EFC", + stamp.id, sufijo, + ) + continue + row = gateway.enqueue_file_best_effort( + db, + kind=kind, + s3_key=s3_key, + file_name=f"CFDI-{stamp.uuid}-{sufijo}.xml", + content_type="application/xml", + efc_tipo=_EFC_TIPO_CFDI, + source_table=SOURCE_FIN_INVOICE_STAMPS, + source_id=stamp.id, + crm_document_ref=f"{prefijo_ref}-{stamp.company_id}-{stamp.id}", + expediente_ref=invoice.case_id, + tenant_id=stamp.tenant_id, + company_id=stamp.company_id, + delete_local=False, + ) + if row is not None: + filas.append(row.id) + return filas + + +def _despachar_xml_al_expediente(outbox_ids: list[int], tenant_id: int, company_id: int) -> None: + """Despacha las filas ya commiteadas. Lo que no se despache lo recoge el sweep del beat.""" + from ...crm.expediente_gateway import service as gateway # noqa: PLC0415 + + for outbox_id in outbox_ids: + gateway.dispatch_file_delivery(outbox_id, tenant_id, company_id) + + def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None: """Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``. diff --git a/backend/tests/test_efc_cfdi_xml.py b/backend/tests/test_efc_cfdi_xml.py new file mode 100644 index 0000000..1c33c9e --- /dev/null +++ b/backend/tests/test_efc_cfdi_xml.py @@ -0,0 +1,215 @@ +"""Entrega al expediente de EFC de los dos XML del timbrado. + +Lo que se fija aquí es el encolado: qué se manda, con qué tipo, y —lo más frágil— que los DOS +XML del mismo timbre lleguen. Comparten ``source_id`` (el id del intento), así que un solo +``kind`` para ambos haría que la guarda de idempotencia se comiera el segundo. + +No se prueba la subida a EFC: eso es ``deliver_file_row``, ya cubierto en las pruebas del outbox. +""" + +import pytest + +from api.v1.modules.crm.cases import service as cases_service +from api.v1.modules.crm.expediente_gateway.models import ( + FILE_KIND_CFDI_REQUEST, + FILE_KIND_CFDI_RESPONSE, + SOURCE_FIN_INVOICE_STAMPS, + STATUS_SENT, + EfcFileOutbox, +) +from api.v1.modules.fin.invoices.models import Invoice +from api.v1.modules.fin.stamping import service as stamping +from api.v1.modules.fin.stamping.models import STATUS_STAMPED, InvoiceStamp +from tests.conftest import COMPANY_ID, TENANT_ID + +UUID = "11111111-2222-3333-4444-555555555555" +KEY_REQUEST = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-request.xml" +KEY_RESPONSE = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-response.xml" + + +@pytest.fixture() +def efc_encendido(monkeypatch): + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) + return settings + + +def _expediente(db): + """Expediente como el que nace con la oportunidad, sin tocar el carril hacia EFC.""" + from api.v1.modules.crm.expediente_gateway import service as gateway + + # ``create_case`` replica el expediente a EFC; aquí solo interesa la fila local. + original = gateway.replicate_expediente_best_effort + gateway.replicate_expediente_best_effort = lambda *a, **k: None + try: + case = cases_service.create_case( + db, TENANT_ID, COMPANY_ID, title="Importación de prueba", user_id="user-1" + ) + finally: + gateway.replicate_expediente_best_effort = original + db.commit() + return case + + +def _factura_timbrada(db, *, case_id, request_key=KEY_REQUEST, response_key=KEY_RESPONSE): + invoice = Invoice( + reference="FAC-001", case_id=case_id, currency="MXN", status="emitida", + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + db.add(invoice) + db.flush() + stamp = InvoiceStamp( + invoice_id=invoice.id, mode="pruebas", status=STATUS_STAMPED, uuid=UUID, + request_xml_file_key=request_key, response_xml_file_key=response_key, + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + db.add(stamp) + db.flush() + return invoice, stamp + + +def _filas(db): + return db.query(EfcFileOutbox).order_by(EfcFileOutbox.id).all() + + +# ── El caso normal ─────────────────────────────────────────────────────────── + +def test_encola_los_dos_xml_del_timbre(db, efc_encendido): + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + ids = stamping._encolar_xml_al_expediente(db, invoice, stamp) + db.commit() + + assert len(ids) == 2 + filas = _filas(db) + assert [f.kind for f in filas] == [FILE_KIND_CFDI_REQUEST, FILE_KIND_CFDI_RESPONSE] + assert [f.s3_key for f in filas] == [KEY_REQUEST, KEY_RESPONSE] + assert [f.file_name for f in filas] == [ + f"CFDI-{UUID}-envio.xml", + f"CFDI-{UUID}-respuesta.xml", + ] + for fila in filas: + assert fila.expediente_ref == case.id + assert fila.source_table == SOURCE_FIN_INVOICE_STAMPS + assert fila.source_id == stamp.id + assert fila.content_type == "application/xml" + assert fila.efc_tipo == "factura_venta" + + +def test_el_tipo_es_uno_que_efc_acepta(db, efc_encendido): + """El tipo viaja al catálogo GLOBAL de EFC: una clave inventada lo contamina.""" + from api.v1.modules.crm.expediente_gateway.doc_types import is_valid_doc_type + + assert is_valid_doc_type(stamping._EFC_TIPO_CFDI) + + +def test_no_borra_la_copia_local(db, efc_encendido): + """El XML timbrado es el comprobante fiscal y el CRM lo sirve por /stamp/xml-url. + + Con ``delete_local=True`` la entrega borraría el objeto de MinIO y esos endpoints quedarían + apuntando a algo inexistente. + """ + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + stamping._encolar_xml_al_expediente(db, invoice, stamp) + db.commit() + + assert [f.delete_local for f in _filas(db)] == [False, False] + + +def test_referencias_distintas_por_xml(db, efc_encendido): + """``crm_document_ref`` es la idempotencia del lado de EFC: un UNIQUE por (org, ref). + + Si los dos XML compartieran la referencia, EFC devolvería el primero al entregar el segundo + y el expediente se quedaría con la mitad del par. + """ + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + stamping._encolar_xml_al_expediente(db, invoice, stamp) + db.commit() + + refs = [f.crm_document_ref for f in _filas(db)] + assert refs == [f"CFDIREQ-{COMPANY_ID}-{stamp.id}", f"CFDIRES-{COMPANY_ID}-{stamp.id}"] + assert len(set(refs)) == 2 + + +# ── La colisión de kinds ───────────────────────────────────────────────────── + +def test_el_xml_ya_entregado_no_bloquea_al_otro(db, efc_encendido): + """La razón de que los kinds sean dos y no uno. + + La guarda es ``(source_table, source_id, kind)`` y los dos XML comparten source_id. Con un + kind común, tener el de envío en 'sent' haría que el de respuesta no se encolara nunca — y + el expediente se quedaría a medias sin un solo error visible. + """ + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + db.add( + EfcFileOutbox( + kind=FILE_KIND_CFDI_REQUEST, s3_key=KEY_REQUEST, file_name="x.xml", + content_type="application/xml", efc_tipo="factura_venta", + source_table=SOURCE_FIN_INVOICE_STAMPS, source_id=stamp.id, + expediente_ref=case.id, status=STATUS_SENT, delete_local=False, + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + ) + db.commit() + + stamping._encolar_xml_al_expediente(db, invoice, stamp) + db.commit() + + nuevas = [f for f in _filas(db) if f.status != STATUS_SENT] + assert [f.kind for f in nuevas] == [FILE_KIND_CFDI_RESPONSE] + + +# ── Los casos en que no hay nada que entregar ──────────────────────────────── + +def test_factura_sin_expediente_no_encola(db, efc_encendido): + """Una factura capturada fuera del ciclo comercial no tiene expediente. No es un error.""" + invoice, stamp = _factura_timbrada(db, case_id=None) + + assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] + assert _filas(db) == [] + + +def test_sin_xml_guardado_se_encola_solo_el_que_existe(db, efc_encendido): + """Si el almacenamiento falló al guardar el intento, no hay objeto que subir.""" + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id, response_key=None) + + ids = stamping._encolar_xml_al_expediente(db, invoice, stamp) + db.commit() + + assert len(ids) == 1 + assert [f.kind for f in _filas(db)] == [FILE_KIND_CFDI_REQUEST] + + +def test_un_fallo_del_carril_no_tumba_el_timbre(db, efc_encendido, monkeypatch): + """El CFDI ya es válido ante el SAT: nada del carril hacia EFC puede propagar.""" + from api.v1.modules.crm.expediente_gateway import service as gateway + + def _explota(*_a, **_k): + raise RuntimeError("EFC en llamas") + + monkeypatch.setattr(gateway, "enqueue_file_best_effort", _explota) + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] + + +def test_con_efc_apagado_no_encola(db, monkeypatch): + """Sin ``EFC_API_URL`` el carril es un no-op y el timbrado sigue igual.""" + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False) + case = _expediente(db) + invoice, stamp = _factura_timbrada(db, case_id=case.id) + + assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] + assert _filas(db) == [] diff --git a/backend/tests/test_efc_outbox.py b/backend/tests/test_efc_outbox.py index f87c97f..902cc8c 100644 --- a/backend/tests/test_efc_outbox.py +++ b/backend/tests/test_efc_outbox.py @@ -20,7 +20,7 @@ from api.v1.modules.crm.expediente_gateway.models import ( EfcFileOutbox, EfcSyncOutbox, ) -from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.cases.models import Case from api.v1.modules.crm.service_requests import service as sr_service from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate from core.efc_client import EfcClientError @@ -49,17 +49,28 @@ def efc_apagado(monkeypatch): return settings -def _expediente(db): - solicitud = sr_service.create_service_request( +def _solicitud(db): + return sr_service.create_service_request( db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" ) - return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + + +def _expediente(db): + """El expediente que nace al crear una solicitud. + + El expediente es ``crm.cases``: nace con la oportunidad o la solicitud y se hereda por + ``case_id``. Se llega a él por esa liga, que es la que existe hoy — no hay una tabla de + expedientes con ``service_request_id`` propio. + """ + solicitud = _solicitud(db) + assert solicitud.case_id is not None, "crear una solicitud debe mintear su expediente" + return db.query(Case).filter(Case.id == solicitud.case_id).one() def _fila_sync(db, expediente, **kwargs): row = EfcSyncOutbox( kind=kwargs.pop("kind", KIND_EXPEDIENTE), - payload=kwargs.pop("payload", {"folio": expediente.folio}), + payload=kwargs.pop("payload", {"folio": expediente.reference}), expediente_ref=expediente.id, status=kwargs.pop("status", STATUS_PENDING), tenant_id=kwargs.pop("tenant_id", TENANT_ID), @@ -352,7 +363,7 @@ def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_ence assert len(filas) == 1 assert filas[0].kind == KIND_EXPEDIENTE assert filas[0].status == STATUS_PENDING - assert filas[0].payload["folio"] == expediente.folio + assert filas[0].payload["folio"] == expediente.reference assert filas[0].payload["storage_token"] == expediente.efc_storage_token @@ -363,21 +374,27 @@ def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendid monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta) - solicitud = sr_service.create_service_request( - db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" - ) + solicitud = _solicitud(db) assert solicitud.id is not None - assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None + # La solicitud sigue en pie CON su expediente: el fallo del carril no deshace ninguno de los dos. + assert solicitud.case_id is not None + assert db.query(Case).filter(Case.id == solicitud.case_id).one_or_none() is not None def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido): - """Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica.""" - expediente = _expediente(db) - solicitud_id = expediente.service_request_id + """Primera guarda de idempotencia: replicar el mismo expediente no genera una segunda fila. - expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1") - expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1") + Se ejercita ``replicate_expediente_best_effort`` directamente, que es el punto de entrada del + carril y donde vive la guarda ``_expediente_ya_encolado``. Antes esto entraba por un + ``ensure_expediente`` del módulo de expedientes, que ya no existe; el invariante es el mismo. + """ + expediente = _expediente(db) + + # La primera réplica ya la hizo create_case al mintear el expediente. + gateway.replicate_expediente_best_effort(db, expediente) + gateway.replicate_expediente_best_effort(db, expediente) + db.commit() filas = db.query(EfcSyncOutbox).filter( EfcSyncOutbox.expediente_ref == expediente.id, diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index b0a432a..ea5a21a 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -68,7 +68,7 @@ def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> # ---------- Catálogos del SAT: lectura ---------- CATALOG_EXPECTATIONS = [ - ("tax-regimes", 19, "601"), + ("tax-regimes", 22, "601"), ("taxes", 3, "002"), ("payment-forms", 22, "03"), ("units-of-measure", 21, "H87"), @@ -111,6 +111,15 @@ def test_tax_regimes_person_type_excludes_individual_only(client): fisica_codes = [r["code"] for r in fisica] assert "605" in fisica_codes and "601" not in fisica_codes + # Claves con vigencia 2024: el 628 es solo de personas morales y el 630 solo de físicas. + assert "628" in codes and "628" not in fisica_codes + assert "630" in fisica_codes and "630" not in codes + + # 616 «Sin obligaciones fiscales» es de persona física en el catálogo del SAT: una cuenta + # marcada como moral NO debe ofrecerlo. Se fija porque es justo lo que se reporta como + # «falta el 616» cuando en realidad está bien filtrado. + assert "616" in fisica_codes and "616" not in codes + def test_products_services_limit_caps_results(client): assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3 diff --git a/backend/tests/test_gateway_rutas.py b/backend/tests/test_gateway_rutas.py index 40494f4..73c0ea7 100644 --- a/backend/tests/test_gateway_rutas.py +++ b/backend/tests/test_gateway_rutas.py @@ -18,7 +18,7 @@ from api.v1.modules.crm.expediente_gateway.models import ( STATUS_SENT, EfcFileOutbox, ) -from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.cases.models import Case from api.v1.modules.crm.service_requests import service as sr_service from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate from tests.conftest import COMPANY_ID, TENANT_ID @@ -40,7 +40,8 @@ def entorno(db, monkeypatch): solicitud = sr_service.create_service_request( db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" ) - expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + # El expediente es ``crm.cases`` y se llega a él por la liga ``case_id`` de la solicitud. + expediente = db.query(Case).filter(Case.id == solicitud.case_id).one() return {"db": db, "expediente": expediente} diff --git a/backend/tests/test_uploads_alcance.py b/backend/tests/test_uploads_alcance.py index 915d0fe..f6a544d 100644 --- a/backend/tests/test_uploads_alcance.py +++ b/backend/tests/test_uploads_alcance.py @@ -10,7 +10,7 @@ almacén de la company». import pytest from fastapi import HTTPException -from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension +from api.v1.modules.crm.uploads.routes import download_file, get_upload_url, validar_extension from tests.conftest import COMPANY_ID, TENANT_ID USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"} @@ -83,3 +83,40 @@ def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable(): def test_la_allowlist_acepta_los_formatos_de_documento(): for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"): validar_extension(nombre) # no lanza + + +# ── /uploads/download: el mismo alcance ────────────────────────────────────── +# +# El endpoint que entrega los BYTES no puede ser más permisivo que el que solo firma una URL: +# sería la puerta grande justo al lado de la que se cerró. + +@pytest.fixture() +def _sin_descarga(monkeypatch): + import api.v1.modules.crm.uploads.routes as uploads + + monkeypatch.setattr(uploads, "get_object_bytes", lambda key: b"contenido") + + +@pytest.mark.parametrize( + "sufijo", + [ + "certificates/fiel_20260101.key", + "invoices/9/cove/cove.xml", + "branding/logo.png", + ], +) +def test_la_descarga_tampoco_alcanza_fuera_de_los_documentos(sufijo, _sin_descarga): + with pytest.raises(HTTPException) as exc: + download_file(PREFIJO + sufijo, COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +def test_la_descarga_si_entrega_un_documento_del_crm(_sin_descarga): + resp = download_file(PREFIJO + "crm-docs/abc123/contrato.pdf", COMPANY_ID, USUARIO) + assert resp.body == b"contenido" + + +def test_la_descarga_no_alcanza_otro_tenant(_sin_descarga): + with pytest.raises(HTTPException) as exc: + download_file("tenants/999/companies/1/crm-docs/a/b.pdf", COMPANY_ID, USUARIO) + assert exc.value.status_code == 403