Plan v3 — Estabilización para la versión estable
Date: 2026-09-13 Status: Listo para ejecutar (F1 a F6). Tres puntos necesitan confirmación de Carlos, marcados con 🟡 Origen: Auditoría pre-versión estable + ADR-010 Relación con el plan v2: es paralelo, no lo reemplaza. E5 está mergeado, E6 (seguridad) y E7 (documentación) siguen pendientes y varios hallazgos de F4/F5 son insumo de E6.
Por qué existe este plan
Cuatro pedidos de Carlos (2026-09-13) sobre una versión que ya está cerca de estable: funcionalidades que faltan para muchos usuarios, el prefondeo revisado de punta a punta, seeders nuevos, y el dashboard probado. La auditoría encontró que dos de esos cuatro tienen la misma causa raíz —los datos de demostración envejecieron y nadie los regenera— y que los otros dos esconden problemas de seguridad que no se ven en una demo.
Orden y dependencias
F1 (datos de demo) ─┬─→ F2 (prefondeo: aviso que falta)
├─→ F3 (dashboard: bugs finos)
└─→ (habilita demostrar todo lo demás)
F4 (credenciales propias) ──┐
F5 (baja efectiva + escalada) ─┼─→ independientes entre sí y de F1-F3
F6 (ABM para muchos usuarios) ─┘F1 va primero y sola. Sin datos que envejezcan bien, ni F2 ni F3 se pueden verificar a ojo, y cualquier QA manual sobre el dashboard vuelve a dar cero. Las demás pueden correr en paralelo en branches separados.
Regla que vale para las seis entregas: cada una termina en su propio branch → tests verdes → code-reviewer sobre el diff → corregir → commit → push → PR. El merge a main lo autoriza Carlos en el chat, como siempre.
F1 — Datos de demostración que no envejecen
Problema: el seeder siembra fechas relativas al momento de correrlo y nunca se vuelve a correr, así que el sistema parece roto a la semana. Y una base fresca hoy no permite pagar ni una transacción. Ver §3 de la auditoría.
F1.1 — Comando demo:reseed, re-ejecutable
Comando artisan nuevo (backend/app/Console/Commands/), bloqueado fuera de local y staging (chequear app()->environment() y abortar en producción, con --force explícito si alguna vez hace falta).
Qué hace, en este orden:
- Borra los datos de demostración que siembra él mismo (transacciones, documentos, movimientos de caja, sesiones, cajas, asientos de las tres capas, tipos de cambio, audit logs de demo). No toca roles, permisos, países, estaciones ni usuarios: eso lo siguen sembrando los seeders idempotentes actuales.
- Vuelve a sembrar todo con fechas relativas al momento de correrlo.
- Imprime un resumen de lo que dejó (cantidades por estado, saldos de las tres capas, rango de fechas) para que se pueda verificar de un vistazo.
Tiene que ser idempotente por re-ejecución: correrlo dos veces seguidas deja el mismo estado, no el doble.
F1.2 — Seeder de transacciones con volumen y profundidad
Reemplaza a TransactionSeeder (o lo deja para el set mínimo y agrega uno de demo; la decisión es de implementación). Requisitos verificables:
| Requisito | Por qué |
|---|---|
≈ 120 ACCEPTED en la bolsa compartida (station_id = NULL) | Pedido explícito de Carlos. Es el corazón de la operación de mostrador y hoy hay 11 |
| ≥ 90 PAID repartidas en los últimos 60 días, con al menos 3 pagadas hoy y 3 ayer | Sin pagos de hoy y de ayer, el KPI estrella y la variación no se pueden mostrar |
| ≥ 20 CANCELLED, con motivo de cancelación variado | La pantalla de cancelación y los reportes hoy no tienen casos |
| Volumen distinto por día (no una progresión lineal) y con al menos un pico y un día flojo | Hoy los montos van de 731,17 en 731,17 y se nota que es sintético |
| Las 8 semanas del gráfico semanal todas con datos | Hoy 7 de 8 están en cero |
| Reparto de PDV realista: GT-CAP-001 (el único del alcance de Backoffice) con volumen alto, no el más bajo | Hoy tiene 2 transacciones y el scoping no se puede demostrar |
Al menos 15 PAID con los datos del pago de E5a completos y con 2–3 transaction_documents cada una | Hoy el reporte regulatorio sale con las columnas de CIS vacías |
| Nombres, montos, nacionalidades y tipos de entrega variados | Para que una demo no se lea como una tabla generada |
Cuidados obligatorios (sale de las trampas ya vividas, §3.5 de la auditoría):
created_at/updated_at/last_updated_atno son fillable: hay que asignarlos y usarsaveQuietly()(patrón deTransactionSeeder.php:102-105).mtcnysoterex_transaction_idson UNIQUE: usar un rango que no choque con el set actual.- Nunca escribir
role_permissionsfuera deRolePermission::withoutEvents(): la cascadacan_write ⇒ can_readrompe la pantalla Pago del Backoffice (RoleSeeder.php:130-134). - Guard antes de indexar arrays de usuarios/estaciones: hoy
TransactionSeeder.php:88-89explota con "Undefined array key" siDevUserSeederse salteó.
F1.3 — Las tres capas de prefondeo, coherentes y demostrables
- Activar el módulo Caja en Guatemala (
modulo_caja = true,local_currency), queCountrySeederhoy deja apagado. - Sembrar
country_prefunding_entries(hoy no lo hace nadie) yprefunding_entries, con asientos escalonados en el tiempo y motivos de tesorería. - Sembrar cajas, una sesión abierta y otra cerrada, movimientos de los cuatro tipos con motivos del catálogo cerrado, y tipos de cambio.
- Dejar los saldos en un estado donde el aviso de F2 se pueda disparar tipeando un número razonable. Hoy haría falta fondear una caja por más de 84.133 para verlo. Concretamente: dejar el disponible para repartir de cada capa en el orden de unos pocos miles, no de decenas de miles.
- Respetar los dos índices únicos parciales de
cash_sessions(una abierta por caja y una abierta por usuario).
F1.4 — Auditoría con cuerpo
Sembrar audit logs de varias acciones distintas (ABM, caja, permisos, exports, login), con entity_id cargado, repartidos en el tiempo, para que la pantalla de Auditoría y sus filtros tengan algo que filtrar. Hoy hay 5 registros de 3 acciones y ningún entity_id.
F1.5 — Documentar el reset
Actualizar doc/environments/dev.md y start-dev.sh (hoy el migrate --seed está comentado) con el camino real de reset: cómo dejar el entorno como recién instalado y cómo regenerar los datos de demo sin perder lo demás.
Terminado cuando: php artisan demo:reseed corrido dos veces seguidas deja un entorno donde (a) el Dashboard muestra números en los siete bloques, (b) se puede pagar una transacción de punta a punta, (c) el módulo Caja tiene saldo, sesiones y movimientos, y (d) el aviso de sobregiro de F2 se puede disparar con un monto de tres cifras.
F2 — Prefondeo: el aviso que falta y los agujeros del que existe
Implementa ADR-010. 🟡 Antes de empezar, confirmar con Carlos la open question del ADR (¿F − Fe − D o F − D?).
F2.1 — Disponible de la Holding (backend)
PrefundingService: método nuevoavailableToDistribute()=Σ entries − Σ fee(PAID) − Σ country_prefunding_entries(o lo que resulte de la confirmación 🟡), ybreakdown()que devuelva las tres cifras juntas, con el mismo formato que ya usaCountryPrefundingService::breakdown().CountryPrefundingController::store(): envolver enDB::transactioncon lock, calcular el disponible después de crear el asiento y devolverwarning: { code: 'OVER_HOLDING_PREFUNDING', message, available_to_distribute }cuando quede negativo — HTTP 201, el asiento se crea igual (ADR-010 §1). Espejo exacto deCashBoxController::overDistributionWarning().CountryPrefundingController::index(): agregar el disponible de la Holding al payload, para que la pantalla donde se carga el asiento pueda mostrar el techo antes de cargarlo.- Mismo tratamiento transaccional para
PrefundingController::store().
F2.2 — Tapar los agujeros del aviso de caja
AJUSTEpositivo dispara el mismo aviso (CashBoxController::ajuste()), igual que el fondeo. Los ajustes negativos no. 🟡 Confirmar con Carlos (pregunta 3 de la auditoría) — si la respuesta es que los ajustes de arqueo quedan exentos, dejarlo escrito en el código y en el ADR en vez de simplemente no hacerlo.- Limpiar
fondeoWarningal cambiar de caja seleccionada (CajaView.vue), hoy sobrevive y puede mostrar un aviso de otra caja.
F2.3 — Los negativos se ven
PrefondeoView.vue: saldo de la Holding en rojo con leyenda cuando es negativo, espejo dePrefondeoPaisView.vue:110-122. Mostrar también "disponible para repartir".PrefondeoPaisView.vue: mostrar el disponible de la Holding y manejar elwarningnuevo con el mismo banner ámbar que usa Caja.
F2.4 — Rótulos y reportes
- Corregir el rótulo del Dashboard:
prefunding_balancees el desglose de la Holding por país (incluye comisión), no el prefondeo del país. Hoy dice "Saldo de prefondeo (País)" y eso induce a conciliar un número equivocado. 🟡 Definir con Carlos si el Dashboard debe mostrar la Holding, el País, o los dos. - Sumar la capa País al reporte de Situación (
ReportesController+SituacionView.vue): tesorería no tiene export de la única capa contra la que concilia.
F2.5 — Tests
- Backend: alta de prefondeo país por encima del disponible → 201 con warning; dentro del disponible → warning
null; el aviso no depende de los pagos (pagar no cambia el disponible de la Holding);AJUSTEpositivo por encima del techo → warning; ajuste negativo → sin warning; dos altas concurrentes no se pisan. - Frontend: crear
PrefondeoPaisView.spec.tsyPrefondeoView.spec.ts(hoy no existe ninguno de los dos) cubriendo el banner del aviso y el saldo negativo en rojo. - No romper los tests que ya afirman el comportamiento correcto de ADR-007 (
CountryPrefundingApiTest.php:70,88,103,TransactionApiTest.php:599,640,797,822): esos siguen valiendo tal cual, porque este plan no convierte ningún aviso en bloqueo.
Terminado cuando: cargar un asiento de prefondeo país por encima de lo que tiene la Holding muestra un aviso en pantalla, el asiento queda registrado igual, y el mismo comportamiento vale para el fondeo y el ajuste de una caja por encima de lo que tiene el país.
F3 — Dashboard: los bugs finos
No hay ningún KPI roto (los 17 tests pasan; ver §2 de la auditoría). Estos seis bugs no explican los ceros que se ven hoy —eso lo arregla F1— pero todos muerden cuando hay datos reales.
| # | Bug | Archivos |
|---|---|---|
| 1 | El ranking de PDVs excluye station_id IS NULL mientras el KPI de "pagado hoy" lo incluye → "Pagado hoy US$12.500" con "Todavía no hay pagos hoy" debajo. Agregar la fila "Sin PDV asignado", igual que ya hace ReportesController.php:224-246 | DashboardController.php:208-229, RankingPdvList.vue |
| 2 | "Últimos 30 días" es 30 días en Dashboard (subDays(29)) y 31 en Reportes (subDays(30)). Unificar, y dejar el criterio escrito en un solo lugar | DashboardController.php:115,137,166, ReportesController.php:23,393 |
| 3 | "Pendientes" es todo el histórico en Dashboard y el rango en Situación. Unificar o rotular explícitamente cada uno | DashboardController.php:54-57, ReportesController.php:319-320, ambas vistas |
| 4 | El ranking incluye PDVs desactivados; Volumen los excluye | DashboardController.php:210-219 vs ReportesController.php:198-201 |
| 5 | El selector de país ofrece países sin permiso de dashboard → 403 en el primer render. Filtrar por el módulo, no solo por asignación | DashboardView.vue:13-14, stores/me.ts:65-69 |
| 6 | now() se recalcula 6 veces por request → un request que cruce medianoche mezcla días. Calcularlo una vez | DashboardController.php:48,114,137,166,187,200 |
Además, en el mismo pase y porque son baratos:
- Memoizar
PermissionService::assignments(), cuyo docblock ya dice que está cacheada por request y no lo está: hoy son ~5 consultas idénticas con 3 eager loads por request de dashboard (PermissionService.php:19-25). - Extender
DatabaseTimezoneTestatransactions.last_updated_at, que es la columna de la que cuelga el dashboard y que el test de regresión del bug de +6 h nunca cubrió. - 🟡 Decidir qué pasa con las ACCEPTED vencidas:
expires_atexiste en el modelo y no se consulta en ningún lado, así que la bolsa de pendientes crece para siempre (pregunta 4 de la auditoría).
F3.1 — Tests que faltan
Backend: PAID sin PDV (el que hoy fallaría); invariante cruzada Dashboard ↔ Reportes con los mismos datos; bordes exactos de la ventana de 30 días; PDV desactivado; consistencia interna del payload (chart_series[29].amount === kpis.paid_today_amount).
Frontend: no existe ningún test del Dashboard. Crear DashboardView.spec.ts y RankingPdvList.spec.ts: shape vacío sin crashear, tarjetas condicionales ausentes sin US$NaN, signo y flecha de la variación, estado vacío del ranking.
Terminado cuando: con los datos de F1 cargados, el Dashboard y Reportes muestran los mismos totales para el mismo período, y los tests nuevos fallan si alguien reintroduce cualquiera de los 6 bugs.
F4 — Que cada persona maneje su propia contraseña
El problema de fondo no es la comodidad: es que hoy el Admin conoce la contraseña de todos, para siempre, y eso vacía de valor probatorio al audit_log de caja y pagos — que es el control que tesorería aceptó a cambio de sacar la firma del Supervisor (ADR-007 §4).
🟡 Antes de empezar, confirmar con Carlos la pregunta 5 de la auditoría: ¿los cajeros tienen mail confiable? De eso depende si el reset es por mail (barato, estándar de Firebase) o asistido por el Supervisor del PDV (más trabajo, y necesita su propio diseño).
- "Olvidé mi contraseña" en el login (
sendPasswordResetEmailde Firebase). - Cambio de contraseña desde adentro de la app, con reautenticación.
- Pantalla de perfil — hoy no existe y está confesado en el código (
AppLayout.vue:200,327); es el lugar natural para el cambio de contraseña y para el "reiniciar recorrido" que hoy cuelga del sidebar. - Cambio forzado en el primer login y después de un reset de Admin: columna nueva en
users, expuesta en/me, con guard en el router. - Tests de los cuatro caminos.
Terminado cuando: un usuario nuevo recibe una contraseña temporal, la cambia obligatoriamente al entrar, y puede recuperarla sin pasar por un Admin.
F5 — Que dar de baja signifique dar de baja (y cerrar la escalada)
Los tres hallazgos de seguridad de la auditoría. Son insumo de E6, no su reemplazo.
F5.1 — Baja efectiva
FirebaseAuthService: agregardisableUser/revokeRefreshTokens(hoy no existen) y llamarlos al desactivar un usuario en el ABM.VerifyFirebaseToken.php:42: pasarcheckIfRevoked = true. Hoy un token ya emitido sigue válido hasta 1 h después de la baja.- Alta de usuario transaccional: si falla el insert local después de crear en Firebase, hoy queda una cuenta huérfana que bloquea el reintento y no se puede reparar por UI.
F5.2 — El login que cuelga
Un usuario desactivado o sin fila local loguea bien en Firebase y la app queda trabada sin mensaje, porque el guard no atrapa el fallo de /me (router/index.ts:322-324). Para mostrador es indistinguible de una caída. Atrapar, mandar a sin-acceso con el motivo, y agregar un manejo central de 401/403 en services/api.ts.
F5.3 — Cerrar la escalada de privilegios
RoleAssignmentController::store() no valida quién otorga qué: con abm_asignacion_roles:write se puede autoasignar Admin en cualquier país. Replicar el chequeo que ya existe en RolePermissionController.php:54-80 (no otorgar lo que el actor no tiene, y auditar el intento denegado).
F5.4 — Que no se pueda dejar el sistema sin administradores
Bloquear: desactivarse a sí mismo, borrar la propia última asignación, y revocarle abm_roles_permisos / abm_asignacion_roles al rol del propio actor.
F5.5 — Auditar los accesos, no solo las acciones
Hoy no quedan registrados: logins fallidos, accesos denegados por permiso, logout, timeout, ni el intento de entrada de un usuario dado de baja. Sin eso no se detecta fuerza bruta ni credenciales compartidas, y no se puede reconstruir quién estuvo adentro durante un descuadre de caja. Incluye ponerle throttle a POST /me/login, que hoy no tiene.
F5.6 — Limpieza del mismo pase
Borrar getSecondaryIdToken() (services/firebase.ts:64-81), que maneja email+password de un segundo usuario y quedó sin llamadores desde E4b.1; y RolesPermisosView.vue, huérfana desde E3.
Terminado cuando: desactivar a un usuario lo deja afuera en el acto, con un mensaje claro en pantalla, el intento queda auditado, y ningún usuario puede otorgarse un permiso que no tiene.
F6 — Que el ABM aguante muchos usuarios
- Alcance por país:
UserController::indexyRoleAssignmentControllerlistan todo sin filtrar porallowedCountryIds. Un "Admin de Guatemala" administra usuarios de todos los países. - Filtros operativos en el ABM: por estado (activo/inactivo), país, PDV y rol. Hoy solo hay búsqueda por texto de email.
pagoyprefondeo_paisen la matriz editable: existen enRoleSeeder, gatean rutas reales y no se pueden editar desde ninguna pantalla (UsuariosView.vue:180-197tiene la lista hardcodeada con 15 de los 17 módulos). Idealmente derivar la lista del backend en vez de hardcodearla.- Permisos en caliente:
/mese pide una sola vez por carga de página. Un permiso revocado sigue mostrando el módulo hasta que el usuario recargue. Refetch con TTL o antePERMISSION_DENIED. - Timeout de inactividad compartido entre pestañas: hoy el timer es por pestaña pero el
signOutse propaga, así que una pestaña olvidada cierra la sesión de la pestaña donde el cajero está trabajando.
Terminado cuando: un Admin acotado a un país no ve usuarios de otro, los 17 módulos se editan desde la UI, y revocar un permiso se refleja sin recargar.
Fuera de alcance de este plan (a propósito)
- E6 — auditoría de seguridad completa. F5 cubre tres hallazgos concretos de auth; E6 sigue debiendo OWASP, IDOR sobre documentos, test de carrera sobre los descuentos y revisión de infra.
- E7 — documentación total. El circuito de las tres capas necesita un diagrama, no prosa (deuda que ADR-007 ya dejó anotada). ADR-010 suma un número más a explicar.
- M5 — integración real con Soterex, que sigue bloqueada por el punto #2b del análisis funcional.
- 2FA y mail transaccional: quedan como P2 de la auditoría. F4 puede necesitar mail; si es así, es la primera pieza de
app/Maildel proyecto y hay que dimensionarlo aparte.
Higiene que se arrastra (barata, hacerla donde toque)
doc/architecture/index.mdno lista ADR-008 ni ADR-009 (ni 010). Actualizarlo.doc/plans/2026-07-25-paquete-reportes-dashboard-auditoria.mdhabla de una "serie de 7 días" del dashboard que ya no existe (hoy son 30 días y 8 semanas).StagingTestUsersSeedertiene contraseñas en claro en el repo y crea esas cuentas en el Firebase real; se puede disparar contra producción desderun-artisan-command.yml.RoleSeedercorre en la rama no condicional deDatabaseSeeder, o sea también en staging y producción, y pisa cualquier permiso editado desde el ABM.

