Saltar a contenido

Cockpit (panel de operación)

El Cockpit es la cara operativa del backend: un conjunto de endpoints REST de monitoreo y de operación, más una interfaz web que los consume para responder, de un vistazo, ¿está vivo el enlace con AFIP?, ¿cuánto se está emitiendo y con qué tasa de éxito? y ¿qué quedó pendiente de resolver?. Para el por qué del ciclo CAE/CAEA que estas métricas reflejan, mirá El ciclo de facturación; acá están los datos exactos.

La UI es una SPA estática (src/main/resources/static/cockpit.html, ~1180 líneas, con el micro-framework reactivo Arrow.js — sin build, sin React/Thymeleaf), servida directamente por Spring. Hace polling a /api/cockpit/* cada ~5 s. WebConfig la mapea a tres rutas que cambian el modo desde la misma página:

Ruta Modo Runner de escenarios
/cockpit y /cockpit-test Test (ambiente de pruebas) Visible
/cockpit-prod Prod (solo monitoreo) Oculto

Además del modo de la ruta, el runner se gatea por configuración del server: la SPA consulta GET /api/cockpit/config al arrancar y, si runnerEnabled=false (perfil prod, fail-closed), oculta el bloque del runner aunque la ruta sea de test. Ver Configuración y perfiles.

La interfaz

La UI v2 muestra toda la operación en una sola página, filtrable por comercio (multi-ente). Vista general, con datos reales del ambiente de prueba:

Cockpit — vista general (multi-comercio)

La fila birds-eye resume la salud de cada comercio de un vistazo: la sucursal con sus POS y su KPI, el gateway en el medio y, a la derecha, el estado del enlace con ARCA/AFIP (online/offline y vigencia del token de acceso):

Cockpit — birds-eye por comercio: sucursal → gateway → ARCA/AFIP

El ABM de comercios se desbloquea con el secret (X-Cockpit-Secret) y permite dar de alta/baja EnteFacturador, cargar su certificado y administrar sus puntos de venta:

Cockpit — ABM de comercios

En modo test, el runner de escenarios ejecuta operaciones contra AFIP homologación (obtener token, dummy, consultar último comprobante, emitir un CAE de prueba, etc.) y muestra el XML SOAP del tráfico:

Cockpit — runner de escenarios

Estas capturas son reales

Se tomaron del cockpit corriendo en http://localhost:8080/cockpit (autenticado) contra AFIP homologación. Para regenerarlas, levantá el backend (ver Setup del entorno) y corré un script de captura Playwright desde e2e/: mandá el header de auth proactivamente (Authorization: Basic …, solo al host local para no romper las fuentes por CORS) y esperá el render de Arrow.js (waitForFunction sobre el contenido) antes de capturar.

Todo cuelga del paquete com.tipre.tifacturaonlinemanager.cockpit. Hay tres bloques bien diferenciados:

Bloque Controller Base path Naturaleza
Monitoreo CockpitController /api/cockpit Solo lectura: métricas, estado AFIP, backlog, KPIs, historia. Ente-aware (filtro ?ente).
ABM fiscal de PVs CockpitAdminController /api/cockpit/sucpospv Escritura de SucPosPV (con dueño idEnteFacturador), gateado por secret.
ABM de comercios EnteFacturadorAdminController /api/cockpit/comercios CRUD de EnteFacturador + cert write-only + recarga de TA, gateado por secret.
Test runner CockpitTestRunnerController /api/cockpit/test Acciones contra AFIP homologación (algunas emiten de verdad).

El Cockpit ya está detrás de autenticación (cockpit-auth)

Todo /api/cockpit/** y la UI (/cockpit, /cockpit-test, /cockpit-prod, /cockpit.html) requieren autenticación vía Spring Security (SecurityConfig): un SecurityFilterChain con HTTP Basic (para la API/runner/scripts) y form login (para el browser). El user store es un InMemoryUserDetailsManager (un único usuario con rol COCKPIT, password BCrypt), con credenciales por env (COCKPIT_USER / COCKPIT_PASS; defaults de dev admin/changeme, que loguean un WARN de arranque). Ver Autenticación del cockpit más abajo.

Los endpoints de facturación SOAP (/TiFacturaOnlineManagerWS/**, /ws/**) y REST quedan abiertos a propósito (permitAll): son máquina-a-máquina (POS legacy sin capacidad de auth, notificador DINO). El ABM (PVs y comercios) suma, además del user/pass, un segundo candado: el header X-Cockpit-Secret. Aun así, no expongas el cockpit a una red no confiable sin haber seteado COCKPIT_USER/COCKPIT_PASS (y COCKPIT_ADMIN_SECRET para el ABM).

1. Monitoreo — CockpitController

@RestController en /api/cockpit. Pensado para polling cada pocos segundos. Combina dos fuentes de verdad:

  • Métricas en memoria (CockpitMetrics): contadores OK/error por operación AFIP, vivos, thread-safe, que se pierden al reiniciar. Es monitoreo operativo, no auditoría.
  • Datos de la DB (CockpitPendientes, CockpitPorPos, CockpitAfipStatus, etc.): backlog, stats por POS y estado del TA.

Cockpit ente-aware — filtro ?ente

Los endpoints de stats/backlog aceptan el query param ?ente para filtrar por comercio (EnteFacturador). Es un CSV multi-select (?ente=1,2List<Integer>); sin el parámetro (o vacío) = Todos, que es el comportamiento previo (paridad single-ente). Lo aceptan: /pendientes, /por-pos, /ultimas-trx, /pos-stats, /server-stats, /kpi y /ranking. Internamente, la agregación (computePosStats) siembra solo los POS de los entes pedidos (SucPosPV.idEnteFacturador IN :entes) y filtra las Trx por t.enteFacturador.id IN :entes. El modelo CockpitStat incluye idEnteFacturador para que la historia también separe por comercio. El endpoint GET /api/cockpit/entes ([{id, cuit, razonSocial}], solo entes habilitado=true) alimenta el listbox de comercios del front, cuyo estado se sincroniza con la URI (?ente=...).

Endpoints

Método Ruta Qué devuelve
GET /api/cockpit/config Config que el frontend necesita al arrancar: {runnerEnabled} (refleja cockpit.runner-enabled del server).
GET /api/cockpit/summary afip (modo/endpoints) + ops (contadores por operación con tasa de éxito) + recentErrors + jobsEnAlerta (jobs con racha de fallos ≥ umbral). El widget principal.
GET /api/cockpit/errors Solo los últimos errores en memoria (buffer acotado a 50).
GET /api/cockpit/entes Lista de comercios habilitados [{id, cuit, razonSocial}] (solo habilitado=true). Pobla el listbox de selección de comercio del front.
GET /api/cockpit/pendientes?ente= Backlog DB: pendientesDeCae, conError, autorizados. ?ente opcional (CSV multi-select; sin él = Todos).
GET /api/cockpit/por-pos?ente=&desde=&hasta= Comprobantes por PV × modalidad (CAE/CAEA) × período. desde/hasta en yyyyMMdd, opcionales. ?ente opcional.
GET /api/cockpit/afip-status Estado del enlace: online (FEDummy), ta (token/expiración) y facturador (CUIT/razón social).
GET /api/cockpit/ultimas-trx?ente=&n=5 Feed de las últimas N transacciones (operación, tipo, resultado, PV, fecha-hora). ?ente opcional.
GET /api/cockpit/jobs Grilla de jobs: 1 fila por Tarea con su última y próxima ejecución, más fallosConsecutivos y enAlerta (fallos ≥ umbral → badge rojo). Ver Jobs Quartz.
GET /api/cockpit/errores?tipo=&desde=&hasta=&max=200 Errores de transacción (POS→servicio y servicio→ARCA) con filtros. desde/hasta en yyyy-MM-dd. Cada fila incluye mensajeCurado (mensaje humano extraído del raw).
GET /api/cockpit/pos-stats?ente= Stats por POS físico (suc, pos): caeOk, caeError, reprocesos. Cuenta las emisiones CAE de ambos protocolos (V1+V2) y siembra en 0 todos los POS configurados en SucPosPV (se ven aunque no hayan facturado). Filtra caea IS NULL (excluye CAEA) y tipofacturacion null/'CAE' (excluye las NC de reconciliación). ?ente opcional.
GET /api/cockpit/server-stats?ente= Totales del server agregando todos los POS. ?ente opcional.
GET /api/cockpit/kpi?ente= KPI global: kpiPct = caeOk·100 / (caeOk+caeError+reprocesos) + arcaOnline. ?ente opcional.
GET /api/cockpit/ranking?ente= Top-10 peores POS por KPI (ascendente; a igualdad, más errores+reprocesos primero). ?ente opcional.
GET /api/cockpit/pos-trx?suc=&pos=&kind= Drill-down (máx. 25, id desc) de un POS. kindok | error | reproceso; cualquier otro valor → lista vacía.
POST /api/cockpit/stats-snapshot Trigger manual de snapshot (inserta uno ahora sin esperar el job de los 5 min). Útil para testear la historia.
GET /api/cockpit/stats-history?hours=24 Historia de KPI en la ventana: arcaUptimePct, serverTrend[], posCount. Límite ~288 puntos (24 h a 5 min).

De dónde sale cada número

pos-stats, server-stats, kpi, ranking y pos-trx cuentan las emisiones CAE de ambos protocolos por POS físico (suc, pos):

  • V2 (NextGen) trae nroSuc/nroPos → se agrupa por esos directamente.
  • V1 (clásico) solo trae ptoVta → se mapea a (suc, pos) vía SucPosPV (pvcae/pvcaea).
  • POS configurados sin movimientos: se siembran en 0 desde SucPosPV, así aparecen en pos-stats aunque no hayan facturado todavía. (En ranking se excluyen los de total 0: un POS sin actividad no es "el peor".)
  • Notas de Crédito de reconciliación (tipofacturacion <> 'CAE') quedan fuera del conteo CAE, igual que las CAEA (caea no nulo).

La agregación V1+V2 ok/error/reprocesos es con GROUP BY en la DB (acotada por #POS, no por #filas, para escalar sobre el histórico V1); el resto de los KPIs derivados se calculan en Java a propósito, por máxima compatibilidad de dialecto entre H2 (tests) y SQL Server (runtime). Ver Persistencia.

Alerta roja de jobs (CockpitJobs) — sin infra de alerting nueva

Cada fila de /jobs lleva fallosConsecutivos (últimas N ejecuciones en ERROR desde la más reciente, capadas al umbral) y enAlerta (fallos ≥ umbral → badge rojo en el cockpit). El umbral es cockpit.jobs-alerta-umbral (default 3). La fuente es la bitácora JobEjecucion en DB, y la superficie es el JSON que la UI ya pollea — no hay canal de alerting nuevo. /summary reexpone los jobs en alerta bajo jobsEnAlerta ([{tareaId, jobNombre, fallosConsecutivos, error}]) para el badge del header. Al cruzar el umbral (no en cada poll) se emite un WARN a tifactura.log, que ops ya mira. Ver Jobs Quartz.

Métricas en memoria (CockpitMetrics)

Las operaciones que se cuentan están en el enum CockpitMetrics.Op:

Op Operación AFIP
TOKEN WSAA LoginCms
CAE WSFE FECAESolicitar
CAEA CAEA (solicitud/consulta)
ULTIMO_CBTE FECompUltimoAutorizado
CONSULTA FECompConsultar
NOTIFICACION email

Cada OpStat expone ok, error, total y successRate (0..100; 100 si no hubo intentos). El buffer de errores recientes está acotado a 50 entradas (recentErrors, más reciente primero). Los clientes AFIP (WSAA/WSFE) alimentan estos contadores al ejecutar cada operación; además, recordError persiste el error vía TrxErrorRegistro para que la grilla /errores lo vea aunque el contador en memoria se haya reseteado.

Mensaje curado del error (MensajeCurador) — legible para grilla y alarmas

Al persistir un TrxErrorLog, TrxErrorRegistro guarda —además del mensaje crudo (hasta 2000 chars)— un mensajeCurado (columna mensajeCurado, máx 500): el mensaje humano extraído del raw por MensajeCurador.curar(...). El raw llega en tres formas, y el curador las reduce por prioridad:

  1. JSON de error del servicio → el campo "message" (des-escapado).
  2. SOAP fault del POS → el texto del Reason (<...:Text>).
  3. Rechazo AFIP → la observación tras observaciones:.
  4. Fallback → el raw tal cual (truncado).

Lo consume la grilla /errores y el job de alarmas (mail/telegram/whatsapp) sin re-parsear XML.

Backfill del mensaje curado al arranque (CuradoBackfillRunner)

Las filas históricas de TrxErrorLog (grabadas antes de existir la columna) quedaron con mensajeCurado = null. Un runner one-time (@EventListener(ApplicationReadyEvent)) las cura al arranque (TrxErrorRegistro.backfillCurado()). Es idempotente: tras la primera corrida no quedan null, las siguientes son no-op. Su fallo no frena el arranque (loguea un WARN no crítico).

Estado AFIP (CockpitAfipStatus)

/afip-status arma tres bloques:

  • online — ping FEDummy a WSFE (no requiere TA). Cacheado 60 s (DUMMY_TTL_MS) para no martillar a AFIP. Reporta appserver/dbserver/authserver y checkedAt. Agrega estado (online | verificando | caido) y fallosConsecutivos con debounce (ver abajo).
  • ta — si el EnteFacturador habilitado tiene token, cuándo expira (expira), si está vigente y minutosRestantes.
  • facturadorcuit y razonSocial del primer EnteFacturador habilitado.

Debounce del estado ARCA — no pintar 'caído' por un blip

AFIP homologación es flaky: un solo FEDummy fallido no debe pintar "caído" en todos los comercios. El bloque online agrega estado con debounce, calculado por ArcaLinkHealth sobre fallos consecutivos:

  • online — el último ping fue OK (resetea el contador).
  • verificando — 1 a UMBRAL_CAIDO−1 fallos seguidos (degradado transitorio).
  • caido — se alcanzó UMBRAL_CAIDO = 3 fallos consecutivos.

Con el enlace degradado (algún fallo), el TTL del cache baja de 60 s a 15 s (DEGRADED_TTL_MS): converge a "caído" en ~1 min en vez de ~3, y detecta la recuperación más rápido. fallosConsecutivos viene en el JSON para diagnóstico. ArcaLinkHealth es thread-safe (el ping puede dispararse desde requests concurrentes). — CockpitAdminGuard (header X-Cockpit-Secret)

Los dos ABM del cockpit (PVs y comercios) son config fiscal sensible: un PV mal cargado factura contra el punto de venta equivocado; un cert mal seteado firma con la identidad equivocada. Por eso, además de la autenticación user/pass de cockpit-auth (sección 5), suman un segundo candado: el header X-Cockpit-Secret, centralizado en el componente compartido CockpitAdminGuard (requireSecret(...)), inyectado en ambos controllers (una sola fuente de verdad del candado).

  • El secret se configura por la env var COCKPIT_ADMIN_SECRET (property cockpit.admin-secret).
  • El front lo manda en el header X-Cockpit-Secret en cada request del ABM.
  • Comparación en tiempo constante (MessageDigest.isEqual) para no filtrar el secret por timing.
  • Fail-closed: si no hay secret configurado, todo el ABM responde 503 (deshabilitado). Si el secret no coincide, 401.

Dos candados en serie sobre el ABM

Una request al ABM atraviesa primero Spring Security (user/pass de cockpit-auth) y después el CockpitAdminGuard (secret). No hay conflicto: la auth de plataforma valida quién entra al cockpit; el secret es un segundo factor que evita que la config fiscal quede a un click de cualquier sesión ya autenticada.

3. ABM de SucPosPV — CockpitAdminController

SucPosPV es el mapeo sucursal + POS físico → punto de venta CAE (y opcionalmente CAEA) de AFIP, con su comercio dueño (idEnteFacturador). Va detrás de cockpit-auth + el secret de la sección 2.

Ente-aware: el PV ahora lleva dueño (idEnteFacturador)

Tras multicomercio, cada SucPosPV pertenece a un comercio (EnteFacturador). El ABM lo refleja:

  • Alta: idEnteFacturador es obligatorio (ausente → 400). El comercio debe existir y estar habilitado (inexistente o deshabilitado → 422, vía EnteInvalidoException). Esto reemplaza el backfill manual en SQL que se hacía antes.
  • Edición: idEnteFacturador es opcional (null → no cambia el dueño); si viene, se revalida existencia + habilitado (422 si no).
  • Listado filtrable: GET /api/cockpit/sucpospv?idEnteFacturador={id} filtra por comercio; sin el parámetro devuelve todos (comportamiento previo preservado).
  • El ABM no relaja la integridad fiscal de multicomercio: la unicidad GLOBAL del PV se sigue validando (validarUnicidad), y un PV sin comercio asignado se rechaza al editar.

Endpoints

Método Ruta Qué hace Códigos
GET /api/cockpit/sucpospv?idEnteFacturador= Lista los SucPosPV. Con idEnteFacturador filtra por comercio; sin él, todos. 200 / 401 / 503
GET /api/cockpit/sucpospv/config Config informativa del ABM (revalidarArcaAlGuardar). 200 / 401 / 503
POST /api/cockpit/sucpospv Alta. Body {suc, pos, pvcae, pvcaea?, idEnteFacturador}. 201 / 400 (sin idEnteFacturador) / 409 (duplicado) / 422 (ente inexistente/deshabilitado) / 502 (AFIP no responde con revalidación ON)
PUT /api/cockpit/sucpospv/{id} Edición (incluye reasignar idEnteFacturador). 200 / 404 / 409 / 422 / 502
DELETE /api/cockpit/sucpospv/{id} Baja. 200 / 404
POST /api/cockpit/sucpospv/{id}/validar-arca Valida los PV de la fila contra AFIP a demanda. 200 / 404 / 409 (PV inexistente/bloqueado) / 502

El cuerpo de alta/edición es {suc, pos, pvcae, pvcaea, idEnteFacturador} donde pvcaea es opcional (nullable) — un POS puede facturar solo CAE — e idEnteFacturador es obligatorio en alta (en edición, null = no cambia el dueño).

Revalidación contra ARCA al guardar

Con cockpit.revalidar-arca-al-guardar=true (env COCKPIT_REVALIDAR_ARCA), el alta/edición valida los PV contra AFIP antes de persistir: si AFIP rechaza un PV, no se guarda (responde 502). Por default es false (no depende de AFIP para editar). Ver Configuración y perfiles.

Las ResponseStatusException se traducen a JSON {status, message} mediante un @ExceptionHandler propio, porque con la cadena de filtros de Security el resolver por defecto las dejaría salir como 500.

4. ABM de comercios — EnteFacturadorAdminController

CRUD de EnteFacturador (tabla EntesFacturadores): el comercio dueño de los PVs, con su CUIT, razón social, flag habilitado (el mismo que lee la resolución de ente en emisión), su certificado .p12 por comercio y los campos de PDF del comprobante (getPdf). Vive bajo /api/cockpit/comercios, detrás de cockpit-auth + el mismo secret X-Cockpit-Secret (sección 2). Reemplaza por UI lo que antes se administraba a mano en SQL. No toca emisión, money-path ni WSDL — es administración pura.

Campos de PDF del comprobante (getPdf)

El ABM administra los dos campos que configuran el PDF por comercio: backGroundFacturaPath (path de filesystem al PDF template del comprobante) y layout (copias: 1=ORIGINAL, 2=+DUPLICADO, 0=sin PDF). EnteFacturadorForm los expone y el service valida layout a 0..2 antes de persistir (rechaza con AbmComercioException); null/vacío = no tocar el valor actual (permite editar sin re-tipear). Ver Email y PDF.

El cert es write-only (la password nunca sale)

La protección es por whitelist, no blacklist: las respuestas del ABM nunca serializan la entidad EnteFacturador, solo el DTO de lectura EnteFacturadorView, que no tiene campo certP12Password (ni getter). Que el campo no exista en el DTO ⇒ Jackson no puede emitirlo bajo ninguna config futura.

  • EnteFacturadorView (lectura) publica: id, razonSocial, cuit, habilitado, certP12Path, certP12Alias, certConfigurado. certConfigurado es un booleano derivado (certP12Password != null && !blank) que deja al front mostrar "Cert: configurado / sin configurar" sin revelar la clave.
  • EnteFacturadorForm (escritura) sí incluye certP12Password. Semántica de set: null/vacío → no toca el valor actual en DB; string no vacío → lo reemplaza. Así se puede editar razón social o el path sin re-tipear la clave.
  • El service tiene prohibido loguear la entidad (su toString() arrastra token/sign) y el valor de la password en cualquier nivel.

Password del cert en texto plano (decisión firme)

La certP12Password se guarda en texto plano en la DB (decisión de producto, "como antes"). Está mitigada —write-only estricto (nunca se devuelve, loguea ni muestra) y detrás de los dos candados del cockpit— pero no cifrada. Encriptarla es trabajo futuro.

Recarga de cert (evict del TA en 2 niveles)

POST /api/cockpit/comercios/{id}/cert/reload invalida el TA cacheado del comercio para que la próxima emisión re-logueé con el cert recién guardado, sin reiniciar la app. El evict limpia dos niveles (vía EnteFacturadorTokenProvider.evict):

  • L1 (memoria): la entrada por CUIT del WsaaTokenCache.
  • L2 (DB): nulea token/sign/expirationTime de la fila del EnteFacturador.

Limpiar solo L1 dejaría el TA viejo VIGENTE en L2 (DB), y la próxima emisión seguiría firmando con el cert viejo — por eso se limpian ambos. Requiere cert configurado (path + password): si no, responde 422. El re-login es lazy (ocurre en la próxima emisión real), no inmediato. La respuesta avisa: "Cert recargado. El TA nuevo se emitirá en la próxima emisión (puede requerir esperar a que expire el TA anterior en AFIP)."

El gotcha de AFIP — 'el CEE ya posee un TA válido'

Al rotar el cert del mismo CUIT, AFIP puede rechazar un LoginCms inmediato si el TA anterior sigue vigente (~12 h). No es una regresión del ABM: es el comportamiento histórico del TA por CUIT. Por eso el evict es best-effort y diferido (no fuerza un LoginCms ya); el operador reintenta tras la expiración. El error de WSAA queda observable en las métricas TOKEN.

Verificación de PVs contra AFIP (F5)

GET /api/cockpit/comercios/{id}/pvs-afip compara los puntos de venta configurados localmente para el comercio contra los que AFIP reporta por FEParamGetPtosVenta (vía PtoVentaConsultaService). Es solo lectura (no toca money-path). Devuelve PvsAfipComparacion (solo números de PV, sin datos sensibles):

Campo Qué lista
pvOkCae / pvOkCaea PV locales (CAE / CAEA) que SÍ existen en AFIP.
pvFaltantesCae / pvFaltantesCaea PV locales (CAE / CAEA) que no están en AFIP.
pvAfipNoConfigurados PV habilitados en AFIP que no están configurados localmente.

Si AFIP falla (timeout, sin TA, cert inválido) responde 502 (AfipException).

Endpoints

Método Ruta Qué hace Códigos
GET /api/cockpit/comercios Lista todos los comercios (incluso deshabilitados), como EnteFacturadorView. 200 / 401 / 503
GET /api/cockpit/comercios/{id} Detalle de un comercio (sin certP12Password). 200 / 404 / 401 / 503
POST /api/cockpit/comercios Alta. Body EnteFacturadorForm (razonSocial, cuit, habilitado, cert opcional, campos PDF opcionales). CUIT y razón social únicos. 201 / 409 (CUIT/razón social duplicados)
PUT /api/cockpit/comercios/{id} Edición de razón social, habilitado, cert y campos PDF (backGroundFacturaPath, layout). El CUIT NO es mutable (se ignora del form). 200 / 404 / 409 / 422 (layout inválido)
PATCH /api/cockpit/comercios/{id}/habilitar Baja lógica inversa: habilitado = true. 200 / 404
PATCH /api/cockpit/comercios/{id}/deshabilitar Baja lógica (habilitado = false). No hay DELETE físico (preserva FKs de SucPosPV y Trx). 200 / 404
PUT /api/cockpit/comercios/{id}/cert Set de cert (certP12Path, certP12Password, certP12Alias); campos null/vacíos no se tocan. La respuesta no trae la password. 200 / 404
POST /api/cockpit/comercios/{id}/cert/reload Recarga de cert: evict del TA (L1+L2). 200 / 404 / 422 (cert no configurado)
GET /api/cockpit/comercios/{id}/pvs-afip Compara PVs locales vs AFIP (FEParamGetPtosVenta). 200 / 404 / 502 (AFIP falló)

Deshabilitar un comercio corta la emisión de sus POS

habilitado = false es el flag que EnteResolver exige true (fail-closed) en el path de emisión: deshabilitar un comercio hace que sus SucPosPV dejen de poder emitir (ENTE_DESHABILITADO), y lo saca del GET /api/cockpit/entes y del selector de emisión. Es el comportamiento correcto (cierre de comercio), pero impacta operación — la UI advierte antes de confirmar.

5. Autenticación del cockpit — cockpit-auth (SecurityConfig)

El cockpit ya no está abierto. SecurityConfig (com.tipre.tifacturaonlinemanager.config) define un SecurityFilterChain que protege el cockpit y deja abiertos los endpoints de facturación.

Qué está protegido y qué no

Rutas Acceso Por qué
/api/cockpit/** (API + runner) Autenticado API del cockpit y runner: solo operadores.
/cockpit, /cockpit-test, /cockpit-prod (+ subpaths) y /cockpit.html Autenticado La UI y sus tres forwards de WebConfig + el static crudo.
/TiFacturaOnlineManagerWS/** (SOAP) Abierto (permitAll) POS legacy sin capacidad de auth (máquina-a-máquina).
/ws/** Abierto Cualquier otro endpoint WS.
Resto (REST de facturación, health, actuator, estáticos) Abierto anyRequest().permitAll().

Los matchers van de más específico a más general: los de cockpit primero, después los permitAll del SOAP/WS y el anyRequest. (Sin listar /cockpit-test, /cockpit-prod y /cockpit.html explícitos, esos caían al anyRequest().permitAll() y quedaban SIN auth — por eso están enumerados.)

Mecanismo, user store y credenciales

  • HTTP Basic (httpBasic) para la API/runner/scripts/tests de integración + form login (formLogin) para el browser. Ambos sobre el mismo filter chain.
  • User store: InMemoryUserDetailsManager con un único usuario, rol COCKPIT, password codificada con BCrypt. No usa la tabla Usuarios legacy.
  • Credenciales por entorno: cockpit.auth.user / cockpit.auth.password (env COCKPIT_USER / COCKPIT_PASS). En dev, defaults admin / changeme (application-dev.yml). No hay credenciales hardcodeadas en el código.
  • Aviso de arranque: si las credenciales están vacías o son las de dev por defecto, SecurityConfig loguea un WARN ("usando credenciales por defecto de dev… configurá COCKPIT_USER/COCKPIT_PASS antes de exponer en producción").

CSRF

CSRF queda deshabilitado para todo lo máquina-a-máquina (ignoringRequestMatchers): /api/** (API del cockpit), /TiFacturaOnlineManagerWS/** y /ws/** (SOAP del POS) y /fecaeaSolicitar (REST del notificador DINO). Esos clientes son máquinas y no mandan token CSRF — con CSRF activo recibirían 403 y se rompería la emisión. CSRF sigue activo solo para el form login del cockpit (browser).

El secret del ABM convive con la auth

El candado X-Cockpit-Secret del ABM (sección 2) no se reemplaza por cockpit-auth: opera como segundo factor dentro de la ruta ya autenticada /api/cockpit/{sucpospv,comercios}. Spring Security verifica user/pass primero; luego el controller verifica el secret.

6. Test runner — CockpitTestRunnerService / CockpitTestRunnerController

@RestController en /api/cockpit/test. Son acciones de validación técnica contra AFIP homologación. Cada método mide durationMs, captura excepciones y devuelve siempre {ok, durationMs, error, …campos} — nunca tira la excepción al cliente. Además el controller envuelve cada llamada en withTraffic(...), que captura el XML SOAP request/response hacia ARCA y lo agrega en la clave traffic: [{dir, action, xml}] para inspeccionarlo desde el panel.

Fail-closed: el runner está apagado por defecto

Todos los endpoints del runner están gateados por la property cockpit.runner-enabled (@Value, default false). Si está en false (perfil prod), cada endpoint responde 403 (requireRunner() tira ResponseStatusException(FORBIDDEN)) — así la página de monitoreo no puede emitir nada aunque alguien pegue a /api/cockpit/test/* por URL. El perfil dev lo activa. Ver Configuración y perfiles.

Algunos endpoints EMITEN comprobantes REALES

POST /test/cae, POST /test/cae-v1, POST /test/cae-tributos, POST /test/cae-factura-a, POST /test/caea-solicitar y POST /test/caea-notificar generan/registran comprobantes reales en homologación. El código loguea un WARN ruidoso ([COCKPIT-CAE-PRUEBA] EMITIENDO CAE REAL…). Solo usar en el ambiente de prueba, nunca apuntando a producción.

Endpoints

Los endpoints que emiten o consultan contra AFIP aceptan, además de sus params propios, la pareja opcional enteId + idModo para elegir por qué identificador se resuelve el comercio (ver "Identificación del comercio" abajo).

Método Ruta Qué hace ¿Modifica?
GET /api/cockpit/test/pos Lista los POS virtuales configurados (SucPosPV): [{suc, pos, pvcae, pvcaea}]. No
POST /api/cockpit/test/token Obtiene/refresca el TA y devuelve vigencia (vigente, expira, minutosRestantes, tokenLen). No (cache-hit si vigente)
GET /api/cockpit/test/ultimo?ptoVta=&tipo=&enteId=&idModo= Último comprobante autorizado (FECompUltimoAutorizado). No
GET /api/cockpit/test/consultar?ptoVta=&tipo=&comprobante=&enteId=&idModo= Consulta un comprobante (cae, resultado, caeFchVto). No
POST /api/cockpit/test/cae Emite un CAE de prueba (Factura B, Consumidor Final, $121) por el protocolo V2 (NextGen): el server numera por ticket + suc/pos (nroTicketPos). Body {suc, pos, nroTicketPos?, enteId?, idModo?}.
POST /api/cockpit/test/cae-v1 Emite un CAE de prueba por el protocolo V1 (clásico): el runner consulta último+1 en el PV y numera él. Body {suc, pos, ptoVta, enteId?, idModo?}; devuelve además ptoVta.
POST /api/cockpit/test/cae-tributos Emite una Factura B con Impuestos Internos (V1) para validar el bloque Tributos contra homologación (reproduce el caso que fallaba con AFIP 10024). Body {suc, pos, ptoVta, enteId?, idModo?}.
POST /api/cockpit/test/cae-factura-a Emite una Factura A completa (3 alícuotas de IVA + 3 tributos, V1) a Responsable Inscripto. El receptor es distinto del emisor (evita AFIP 10069). Body {suc, pos, ptoVta, enteId?, idModo?}.
GET /api/cockpit/test/dummy FEDummy: aliveness de los 3 subsistemas AFIP. No
GET /api/cockpit/test/validar-pv?suc=&pos=&enteId=&idModo= Valida que el pvcae del POS esté activo en AFIP (FEParamGetPtosVenta): encontrado, bloqueado. No
GET /api/cockpit/test/getcaea?enteId=&idModo=&suc=&pos= Espeja el entry point del POS (GET /caeaWS/getCaea): resuelve el comercio por cuit (v2) o ente.id (v1) y devuelve cantCaeasVigentes + cuit?/nropvcae?/nropvcaea?. Valida la resolución "aceptar ambos". No
GET /api/cockpit/test/caea-consultar?periodo=&orden= Consulta el CAEA de la quincena (lectura). No
POST /api/cockpit/test/caea-solicitar?periodo=&orden= Solicita un CAEA a AFIP (idempotente por quincena).
POST /api/cockpit/test/caea-notificar Registra un comprobante CAEA de prueba (Factura B, CF, $121). Body {suc, pos, enteId?, idModo?}.

El CAE de prueba es idempotente por ticket

POST /test/cae acepta un nroTicketPos explícito. Re-enviar el mismo ticket ejercita el anti-doble-facturación del flujo real V2: devuelve el CAE existente en vez de emitir de nuevo. Si no se pasa, usa TEST-<timestamp> (siempre nuevo). El campo de ticket en la UI solo aparece en V2; en V1 el server numera último+1 y no hay ticket.

Identificación del comercio en el runner — enteId + idModo (valida 'aceptar ambos')

Cada escenario declara qué comercio usa y por qué identificador, para validar desde el cockpit que la resolución del flujo real acepta ambos:

  • idModo = "cuit"v2: el runner declara SOLO el cuit del comercio (resuelto desde enteId).
  • idModo cualquier otro (o "ente") → v1: declara SOLO el ente.id.
  • enteId ausente → back-compat: primer comercio habilitado (single-comercio).

El TA que usa cada escenario es el del ente resuelto (EnteFacturadorTokenProvider.get(ente)), no un TA genérico: así el TA coincide con el CUIT y se evita el mismatch TA/CUIT en multi-comercio. Los escenarios de emisión V1 (cae-tributos, cae-factura-a) ahora llevan tributos y, la Factura A, receptor RI distinto del emisor (AFIP rechaza 10069 si DocNro = CUIT emisor).

En la UI del runner

El escenario "Obtener CAE" ofrece un selector de Protocolo POS que decide a qué endpoint pega el front: V2 · NextGen (/test/cae, default) o V1 · clásico (/test/cae-v1). El panel de resultado muestra el protocolo usado (V1 · fecaeSolicitar / V2 · fecaeSolicitar (nroTicketPos)) — ambos por la misma operación SOAP fecaeSolicitar, distinguidos por nroTicketPos.

En los escenarios que piden tipo de comprobante (Último comprobante y Consultar comprobante), el tipo dejó de ser un campo libre: ahora es un listbox restringido a los tipos permitidos [1, 3, 6, 8, 11, 13] (Facturas y Notas de Crédito A/B/C; sin Notas de Débito).

Manejo de errores (CockpitExceptionHandler)

Los endpoints del cockpit son nuevos: no forman parte del contrato legacy SPEC-009 (que replica el DefaultExceptionHandler global con 500 + headers exception-class/exception-message + stacktrace en el body — ver API REST). Para no filtrar estructura interna, un @RestControllerAdvice scopeado al paquete com.tipre.tifacturaonlinemanager.cockpit (@Order(HIGHEST_PRECEDENCE)) los maneja distinto:

  • ResponseStatusException (status intencionales: 403 runner off, 401/503 del ABM, etc.) → ese mismo status con body {ok:false, error:<reason>}.
  • Cualquier otra excepción → 500 con {ok:false, error:"Error interno del cockpit"}; el detalle completo (con stacktrace) queda solo en el log del server, nunca en la respuesta.

Como está scopeado por basePackages, no toca los controllers de negocio (web.*) ni el SOAP; y los @ExceptionHandler propios de cada controller (p.ej. el {status, message} del ABM) siguen teniendo prioridad. El hardening de errores de mapeo (404/405, que se lanzan antes de resolver el controller) vive aparte en MappingErrorHardeningHandler — ver API REST.

Para emitir un CAE de prueba paso a paso, mirá la guía de tareas.

Por dónde seguir