Skip to content

Plan de Desarrollo por Módulos — Integración Soterex

Status: Approved (en ejecución) Última actualización: 2026-07-25 Autor: Product Owner Agent (Raxar Factory)

Referencia al Análisis Funcional

→ Análisis completo: doc/functional/2026-07-24-soterex-integracion-functional-analysis.md → Sitemap: sección 1 · Matriz de permisos: sección 2 · Flujos: secciones 3–4 · Pantallas: sección 5

Este documento es el PRD liviano por módulo + orden de ejecución. Cada módulo se detalla al máximo cuando entra en desarrollo; los siguientes quedan resumidos con su alcance y dependencias.

Orden de ejecución y por qué

#MóduloDepende deEstado
M1Transacciones + fundaciones de API (usuario, permisos, /api/me)seed existente✅ Implementado, en QA manual del usuario
M2Prefondeo (saldo por país, asientos)M1 (permisos, saldo ya calculado en M1)✅ Implementado, en QA manual del usuario
M3Dashboard + ReportesM1+M2 (datos de pagos y asientos)✅ Implementado, en QA manual del usuario
M4ABMs de Administración (usuarios, países, PDVs, roles/permisos, asignaciones, configuración, auditoría)M1 (fundaciones)✅ Implementado 2026-07-27, en QA manual del usuario
M5Integración Soterex real (tokenC2P, SendRequest cron, Cancel, Notifications)M1 (máquina de estados) + ⚠️ #2b + credenciales sandbox⛔ Bloqueado parcial

Veredicto sobre el orden: M1 primero porque es el flujo core del negocio (funcional §3.2) y porque sus fundaciones (resolución de usuario, permisos L/E/B por país/PDV, auditoría, saldo de prefondeo) las necesitan todos los demás módulos. M5 al final: es el único bloqueado por la pregunta abierta #2b y por credenciales de sandbox que todavía no están; mientras tanto el seed cumple el rol de la ingesta. La máquina de estados de M1 deja los seams (puntos de enganche) para que M5 agregue las llamadas a Soterex sin refactor.

Decisiones tomadas en este plan (backend-dev, alineadas al funcional)

  1. transactions.station_id (nullable): el scaffold no tenía PDV en la transacción, pero el funcional exige filtro por PDV (§3.2), permisos acotados por PDV (§2.3) y reporting por PDV (§6-#11). Se agrega FK nullable a stations. Pendiente menor (no bloqueante): confirmar qué campo del SendRequest real mapea al PDV — mientras tanto lo asigna el seed.
  2. Descuento de prefondeo = cálculo, no ledger: saldo del país = Σ asientos − Σ (monto+comisión) de transacciones PAID. Los asientos y las transacciones son el ledger; no se duplica en otra tabla. El "descuento" del funcional §4.2 ocurre al quedar la transacción en PAID.
  3. Alcance por PDV: usuario con asignación acotada a PDVs ve solo transacciones de esos PDVs (las sin PDV asignado no le aparecen). Usuario con asignación sin PDVs (Admin típico) ve todo el país.
  4. Transiciones de estado: ACCEPTED → PAID, ACCEPTED → CANCELLED; PAID/CANCELLED son terminales (error 409, referencia al código 2108 del funcional §4.3).
  5. Errores de API: siempre { "code": "...", "message": "..." } con HTTP semántico (401/403/404/409/422). El frontend mapea code, nunca parsea message.

M1 — Transacciones + fundaciones (PRD)

Problema: hoy el equipo opera transacciones en un Google Sheet sin permisos, sin trazabilidad y con cálculo manual de comisión/prefondeo (funcional §0).

Historias:

  • Como Backoffice, quiero listar/filtrar transacciones (estado, país, PDV, fecha) y ver el detalle para gestionar los pagos del día. (§3.2, §5)
  • Como Backoffice, quiero pasar una transacción ACCEPTED a PAID, con bloqueo automático si el saldo de prefondeo del país no alcanza. (§3.2, §4.2)
  • Como Backoffice, quiero cancelar una transacción ACCEPTED. (§3.3 — la llamada al Cancel de Soterex se engancha en M5)
  • Como Supervisor/Admin, quiero que cada acción quede en el log de auditoría con usuario y fecha/hora. (§0, §3.2)
  • Como usuario, quiero ver solo los módulos y datos (países/PDVs) que mis roles permiten. (§2.3)

API:

MétodoRutaPermisoNotas
GET/api/mesesiónusuario + asignaciones (país, rol, PDVs) + permisos efectivos por módulo
GET/api/transaccionestransacciones:Lfiltros status, country_id, station_id, from, to, search (MTCN/nombre); paginado; siempre acotado a países/PDVs del usuario
GET/api/transacciones/{id}transacciones:Ldetalle + historial (audit logs de la transacción)
PATCH/api/transacciones/{id}/paytransacciones:E409 si no está ACCEPTED; 422 INSUFFICIENT_PREFUNDING si monto+comisión > saldo país; audita
PATCH/api/transacciones/{id}/canceltransacciones:E409 si no está ACCEPTED; audita; seam para Cancel+Notifications de Soterex (M5)

Criterios de aceptación clave:

  • Dado un usuario sin fila en users o inactivo, cualquier endpoint devuelve 403 USER_NOT_PROVISIONED/USER_INACTIVE.
  • Dado Backoffice acotado a GT-CAP-001, el listado no muestra transacciones de otros PDVs ni sin PDV, y pagar una transacción de otro PDV devuelve 404. Corregido 2026-07-25 — ver "Fix crítico" más abajo: este criterio era el bug exacto (ACCEPTED nunca tiene PDV, es bolsa compartida por país). El acotamiento por PDV solo aplica a transacciones ya resueltas.
  • Dado un país con saldo 73.000 y una transacción de 95.000+comisión, pay devuelve 422 con el saldo actual y la transacción queda ACCEPTED.
  • Dado un pay exitoso, el saldo disponible baja en monto+comisión y queda un audit log TRANSACTION_STATUS_CHANGE con el usuario.
  • Dos pay concurrentes sobre la misma transacción: solo uno gana (lock pesimista); el otro recibe 409.
  • El sidebar del frontend solo muestra módulos con Leer=ON para algún rol del usuario.

Fuera de alcance M1: llamadas reales a Soterex (M5), mails a PDVs, exportaciones, edición de datos de la transacción (solo cambia el estado).

Fix crítico 2026-07-25 — el PDV se asigna al resolver, no al ingresar

M1 salió con un supuesto incorrecto (documentado arriba, tachado): que la transacción trae PDV desde el ingreso. El cliente confirmó la regla real — ver doc/plans/2026-07-25-fix-pdv-asignacion-transacciones.md y §2.4 del funcional (nueva):

  • ACCEPTED es una bolsa compartida por país — sin PDV, visible para cualquier Backoffice/Supervisor con acceso a ese país, sin importar su PDV asignado.
  • El PDV (station_id) se graba recién al resolver (PAID/CANCELLED), junto con resolved_by_user_id (nuevo campo) — sin ambigüedad si el usuario tiene un solo PDV asignado; con 2+ PDVs queda null (pregunta #14 del funcional, abierta, no bloqueante).
  • El acotamiento por PDV de la matriz de permisos aplica solo a lo ya resuelto (reporting).
  • Reforzado el re-chequeo de estado con lock pesimista justo antes de escribir (ya existía desde M1, se mantuvo) — ahora es más probable que colisione, al ser una bolsa realmente compartida.

Cambios: TransactionController (scoping condicional por estado en index/authorizeScope, asignación de PDV+resolvedor en pay/cancel, mensaje 409 más claro), TransactionSeeder (ACCEPTED sin PDV, resueltas con PDV+resolvedor), migración resolved_by_user_id, frontend (filtro de PDV deshabilitado salvo estado PAID/CANCELLED, detalle se refresca solo tras un 409). 6 tests nuevos + 1 corregido (37 en total). QA de M1 actualizado con la sección 8 (bolsa compartida y concurrencia con 2 pestañas).

Segunda vuelta, mismo día — 2 bugs más encontrados en uso real (no en los tests):

  1. Verificando el fix con 2 pestañas reales: un usuario acotado a un PDV que perdía la carrera contra alguien que resolvía sin PDV (ej. Admin) recibía 404 en vez de 409 al intentar pagar. El acotamiento por PDV no tiene sentido para escritura bajo el nuevo modelo — corregido junto con el frontend (el detalle ya no rompe con "no encontrada" si el refresco tras un 409 también da 404: se queda con los datos que el usuario ya veía + aviso claro).
  2. El cliente pagó una transacción real como Admin y no le quedó PDV asignado — comportamiento documentado (pregunta #14 abierta) pero no el que quería. Se agregó default_station_id en role_assignments ("PDV activo", no cambia el alcance de lectura) y se configuró a Admin en GT-CAP-001. Dato ya pagado corregido a mano. Ver pregunta #14 del funcional (actualizada) y el checklist de doc/plans/2026-07-25-fix-pdv-asignacion-transacciones.md.

41 tests en total.

Mejoras al listado 2026-07-27 — filtros de fecha, ordenamiento, paginado y fix de sidebar

Pedido del cliente sobre el listado de Transacciones — ver doc/plans/2026-07-25-mejoras-listado-transacciones.md:

  • Filtro de fecha desde/hasta (inclusivo del día completo en hasta), combinable con los filtros existentes.
  • Nueva columna "Creada" (created_at), distinta de "Última actualización".
  • Ordenamiento asc/desc en las 9 columnas del listado, con whitelist estricta en el backend (TransactionController::SORTABLE_COLUMNS) — el valor de sort nunca llega directo a un orderBy(), siempre se resuelve contra el mapa de columnas reales antes.
  • Paginado real en el frontend (<n-pagination>), con last_page agregado a la respuesta.
  • Fix del sidebar: dejaba de resaltar "Transacciones" al entrar al detalle (/transacciones/:id) porque el active-class nativo de Vue Router solo propaga entre rutas ancestro/descendiente en el árbol de rutas, no por prefijo de URL — reemplazado por un isActive(path) propio basado en el path.

6 tests nuevos (47 en total).

Rediseño del modal de confirmación 2026-07-27 — recibo en vez de confirm() nativo

Pedido del cliente — ver doc/plans/2026-07-25-modal-confirmacion-rediseno.md. Reemplaza el confirm() nativo del navegador en pagar/cancelar por un componente propio (ConfirmActionModal.vue, ya armado/validado en otra sesión) con layout tipo recibo (monto en grande, ícono semántico verde/rojo). Éxito, conflicto 409 y saldo insuficiente ahora se muestran con useNotification() (flotante, arriba a la derecha) en vez del cartel rojo fijo en la página.

De paso se encontró y corrigió un gap real: App.vue nunca tuvo n-config-provider, así que todo componente de Naive UI (incluido el <n-pagination> del listado) quedaba con el tema claro por dentro sin importar el modo oscuro de la app — Tailwind (.dark en <html>) y el theming interno de Naive UI son sistemas independientes. Agregado en App.vue, wireado a useDarkMode().

M2 — Prefondeo (implementado)

API: GET /api/prefondeo?country_id= — listado paginado + balance (reusa PrefundingService de M1) + country_id resuelto, acotado a los países donde el usuario puede Leer prefondeo (sin acotar por PDV — es pool único por país, funcional §3.4/§4.2). Si el usuario tiene un solo país asignado, se autoselecciona. POST /api/prefondeo — alta de asiento (country_id, amount), valida amount > 0, chequea permiso de Escribir para ese país específico (no solo el genérico del middleware), audita PREFUNDING_ENTRY_CREATED.

Frontend: PrefondeoView — saldo destacado, selector de país (solo si el usuario tiene más de uno asignado), formulario de alta visible solo con permiso de Escribir, listado histórico (fecha, monto, cargado por).

Tests: 7 tests de feature (permisos de lectura/escritura, acotado por país, alta suma al saldo y audita, rechaza monto ≤ 0). Verificado en browser real: Supervisor carga un asiento y el saldo se actualiza en vivo; Backoffice ve el mismo saldo/listado pero sin el formulario (matriz: Backoffice prefondeo = L·—·—).

Fuera de alcance M2: el funcional menciona "fecha" en el formulario de alta (§5) — se interpretó como la fecha de carga real (created_at), no un campo editable separado; no hay caso de uso descrito que justifique una fecha de vigencia distinta al momento del asiento.

M3 — Dashboard + Reportes (implementado 2026-07-27)

Agregados del funcional #11: pagadas por rango de fecha/PDV/país, comparativas país vs país y PDV vs PDVs, ranking de PDVs, totalizadores. Estado de situación: todos los movimientos (asientos + transacciones) con resumen por request. Export Excel/PDF y filtro de PDV en Estado de Situación: implementados 2026-07-27, ver addendum al final de esta sección.

Backend: DashboardController (GET /api/dashboard) y ReportesController (GET /api/reportes/situacion, GET /api/reportes/volumen), mismo patrón que PrefundingController (país auto-seleccionado/explícito, scoping por país+PDV vía PermissionService, bolsa compartida de ACCEPTED nunca acotada por PDV). Sin cambios a RoleSeederdashboard y reportes ya estaban seedeados (Supervisor+Admin, sin Backoffice).

Regla de comisión aplicada con matiz (ver doc/plans/2026-07-25-restringir-comision.md): en Reportes la comisión va siempre visible (ambos roles); en el Dashboard, solo el rol Supervisor ve el KPI de comisión del día — ni siquiera Admin, aunque tenga el mismo permiso de módulo. El diferenciador es el nombre literal del rol (role.name === 'Supervisor'), no el nivel de permiso — verificado con 2 roles con permisos idénticos y nombres distintos, confirmado interceptando la respuesta real de /api/dashboard (no solo el DOM).

Volumen y Comparativas: el ranking de PDVs incluye los que no tuvieron actividad en el rango (0 pagadas) — un ranking real no debe omitir la población completa —, y reconcilia con "Totalizadores por país" incluso para un usuario acotado a un subconjunto de PDVs (una fila sintética "Sin PDV asignado" cubre el caso de PAID sin PDV asignado, pregunta #14 del funcional, solo visible para usuarios sin restricción).

Frontend: Reportes pasó a ser una sección con tabs (mismo patrón que Admin — Estado de Situación / Volumen y Comparativas), ambas bajo el mismo meta.module: 'reportes' porque la matriz de permisos no las separa. Se sacó el bloque "Demo de notificaciones flotantes" del Dashboard (scaffolding ya superado — el pay/cancel real ya usa notificaciones desde el rediseño del modal de confirmación).

17 tests nuevos (70 en total).

Addendum 2026-07-27 — Dashboard con datos reales, exports Excel/PDF, filtro de PDV, multi-select en Auditoría

Paquete único de 5 puntos pedido por el cliente (ver doc/plans/2026-07-25-paquete-reportes-dashboard-auditoria.md, detalle completo ahí):

  1. Fix visual del filtro de PDV de Transacciones (caption explicando por qué está deshabilitado en ACCEPTED).
  2. DashboardController deja de ser placeholder: variación del monto pagado vs. ayer, serie de los últimos 7 días y ranking de PDVs del día, todo agregado real y con scoping de PDV respetado. Frontend con apexcharts/vue3-apexcharts (gráfico de área animado).
  3. Export de Estado de Situación a Excel/PDF con membrete de marca — barryvdh/laravel-dompdf + phpoffice/phpspreadsheet directo (no maatwebsite/excel, incompatible con Laravel 13+PHP 8.5, ver el porqué en el paquete doc) vía App\Services\ReportExportService, compartido con Auditoría. Requirió agregar ext-gd real a backend/Dockerfile.dev (dompdf lo necesita en runtime para incrustar el logo).
  4. Filtro de PDV en Estado de Situación (excluye ACCEPTED y prefondeo del listado cuando se filtra por un PDV puntual; summary sigue siendo país-wide).
  5. Auditoría: filtro de acción pasa a multi-select (actions[]) + export a PDF (mismo membrete, tope de 1000 filas).

130 tests backend en total (351 assertions), suite completa verde. Sin navegador disponible en esta sesión — verificado con vue-tsc/vite build + HTTP real vía curl contra el stack local; verificación visual pendiente de confirmación manual del usuario.

M4 — ABMs de Administración (implementado 2026-07-27)

Las 7 fases del plan (ver doc/plans/2026-07-25-plan-desarrollo-modulos.md original y el plan de ejecución detallado) — Países → Estaciones → Usuarios → Roles y Permisos → Asignación de Roles → Timeout de sesión + Configuración → Auditoría — implementadas en ese orden, con la suite completa corriendo (canario de aislamiento + tests + vue-tsc) después de cada una.

Países / Estaciones-PDV: ABMs simples, patrón genérico §3.5 (Guardar → Validar → Log). Países agrega commission_pct editable. Estaciones agrega el campo responsable (no existía) y mail(es) de notificación editables (alta/sincronización completa en un solo submit). Ninguno usa PermissionService — son superficies globales de Admin (perm:{module},{action} alcanza, igual que ya hacía SoterexSettingController).

Usuarios: el alta crea la cuenta real en Firebase (FirebaseAuthService, nuevo) — sin eso el usuario no podría autenticarse nunca. Bifurca en use_emulator: contra el emulador, réplica de la receta REST cruda que ya usa DevUserSeeder (el SDK de kreait falla ahí con invalid_grant, intenta un canje OAuth real incluso apuntado al emulador); contra Firebase real, el SDK de kreait sin problema. La única pieza sin precedente en el repo era el update de email contra el emulador (accounts:updateDevUserSeeder solo crea/busca, nunca actualiza); quedó probada de verdad, no solo por analogía: test de feature que crea el usuario, actualiza el mail, y confirma contra el emulador con un accounts:lookup fresco que el mail nuevo resuelve. Contraseña generada server-side (Str::password(20)), mostrada una única vez (alta y "restablecer contraseña" comparten el mismo patrón). La baja nunca toca Firebase — active=false alcanza, ResolveApiUser ya bloquea usuarios inactivos aunque su token de Firebase siga siendo válido (confirmado con un test que hace ambas cosas en la misma corrida).

Roles y Permisos: la dependencia existente Borrar→Escribir (silenciosa, RolePermission:: booted()) se extiende a Escribir→Leer, simétrica — la dependencia E→L se adopta, ya resuelto en este mismo doc. Es solo para la ACTIVACIÓN (cascada hacia arriba); la DESACTIVACIÓN de un permiso mientras el de arriba sigue activo ahora se bloquea con 422 (DEPENDENCY_BLOCKED), no se auto-corrige en silencio — esto es lo que pide el funcional §3.6 ("bloquea la acción") y que el modelo por sí solo no hacía. Frontend: primer uso de <n-checkbox> en el proyecto.

Asignación de Roles: valida que el trío (usuario, país, rol) sea único con un 422 legible (no un error crudo de constraint), y — esto no estaba forzado antes, solo asumido — que cada PDV elegido pertenezca al país de la asignación (pregunta #13b del funcional). Test de integración end-to-end que cierra el círculo con PermissionService de M1: una asignación recién creada vía el endpoint real efectivamente acota lo que ese usuario ve en /api/transacciones.

Timeout de sesión (3 niveles): el auth de esta app es 100% stateless (token de Firebase por request, sin sesión de servidor) — no había ningún mecanismo de expiración previo. Se resuelve puramente en el frontend: un timer de inactividad (useInactivityTimer.ts, mismo patrón de estado compartido que useDarkMode.ts) que escucha actividad real del usuario y también cada llamada a la API, revisa cada 5s contra el timeout resuelto por PermissionService:: resolveSessionTimeoutMinutes() (usuario > rol > default global — con 2+ roles de override distinto, gana el más restrictivo), y al cumplirse cierra la sesión de Firebase y redirige a /login?reason=timeout. Limitación aceptada y documentada, no bloqueante: el timer es por pestaña — sin BroadcastChannel entre tabs, una pestaña inactiva puede cerrar sesión mientras otra activa sigue con la suya. Se aprovechó para enganchar la auditoría de LOGIN (POST /me/login, sin gate de permiso — cualquier autenticado audita el suyo), que AuditLogSeeder ya esperaba con ese shape exacto (metadata: {ip, user_agent}) sin que existiera el hook todavía.

Auditoría: 100% de solo lectura — no existe ninguna ruta PUT/DELETE (confirmado con un test que le pega a las 3 y espera 405), coherente con que ningún rol tiene escritura sobre el módulo en el seed. Filtros por acción/email del actor/rango de fecha (inclusivo del día completo, mismo criterio que Transacciones). El frontend arma un resumen legible por tipo de acción para los casos fáciles (TRANSACTION_STATUS_CHANGE → "ACCEPTED → PAID (MTCN ...)", un diff genérico de antes/después para cualquier metadata con esa forma) y cae a un <pre> con el JSON crudo para el resto.

Bug de infraestructura encontrado y corregido en el camino (no de M4, preexistente en todo el repo): tsconfig.app.json nunca tuvo el paths que mapea el alias @/* a src/* — solo estaba declarado en vite.config.ts (que Vite sí lee). Como consecuencia, vue-tsc -b (el gate real de tipos, según npm run build) nunca había corrido limpio en este proyecto para ningún módulo anterior — fallaba en el primer import @/... de cualquier archivo. Se agregó el paths faltante y se corrigió el único error real que quedó expuesto detrás (dos parameter properties en ApiError de services/api.ts, incompatibles con erasableSyntaxOnly). Con eso, npm run build corre limpio por primera vez en la historia del repo.

45 tests nuevos (115 en total): 7 Países + 5 Estaciones + 6 Usuarios + 5 Roles y Permisos + 7 Asignación de Roles + 5 Timeout de sesión + 5 /me (resolución de timeout) + 5 Auditoría. Verificado además con requests HTTP reales contra el stack levantado (no solo PHPUnit) en cada fase — alta/login/update-email/reset-password de Usuarios contra el emulador real de Firebase, cascada y bloqueo 422 de Roles y Permisos, ciclo completo de Asignación de Roles, resolución de timeout en las 4 combinaciones, y los 3 roles reales del seed (Admin/Supervisor/Backoffice) contra /api/admin/auditoria. No verificado en navegador real (sin herramienta de browser disponible en la sesión): el timer de inactividad del frontend no se ejerció con clicks/tiempo real — la lógica es simple y sigue un patrón ya probado (useDarkMode.ts), pero el auto-logout en sí no se vio disparar en vivo.

Ver plan de pruebas manual: doc/qa/2026-07-27-m4-abms-administracion-plan-de-pruebas.md.

Comisión restringida 2026-07-27 — regla de negocio + seguridad (no solo visual)

Ver doc/plans/2026-07-25-restringir-comision.md y §2.5 del funcional. La comisión CIS de una transacción no se muestra en ningún contexto operativo (listado, detalle, modal de pago/cancelar) — solo en Reportes y el Dashboard de Supervisor (ninguno existe todavía como endpoint real, M3 pendiente). Ocultar la columna en el frontend no era suficiente — el backend seguía devolviendo fee en la respuesta, visible igual por las DevTools. Arreglado con TransactionResource (app/Http/Resources/), que hoy excluye fee siempre (no hay consumidor real de Reportes/ Dashboard aún para condicionar sobre eso — se deja documentado como punto de extensión para cuando M3 exista).

Comisión restringida 2026-07-27 — regla de negocio + seguridad (no solo visual)

Ver doc/plans/2026-07-25-restringir-comision.md y §2.5 del funcional. La comisión CIS de una transacción no se muestra en ningún contexto operativo (listado, detalle, modal de pago/cancelar) — solo en Reportes y el Dashboard de Supervisor (ninguno existe todavía como endpoint real, M3 pendiente). Ocultar la columna en el frontend no era suficiente — el backend seguía devolviendo fee en la respuesta, visible igual por las DevTools. Arreglado con TransactionResource (app/Http/Resources/), que hoy excluye fee siempre (no hay consumidor real de Reportes/ Dashboard aún para condicionar sobre eso — se deja documentado como punto de extensión para cuando M3 exista).

Encontrado en el camino: el 422 de saldo insuficiente devolvía required (amount+fee), que junto al amount ya público permitía derivar la comisión exacta restando — sacado, queda solo balance. La whitelist de sort todavía aceptaba comision — sacada también.

6 tests nuevos (53 en total). Verificado interceptando la respuesta real de red (Network), no solo el DOM — ver el plan doc para el detalle.

Reestructuración de navegación 2026-07-27 — Admin agrupado con tabs (previo a M4)

Independiente de la implementación real de M4 (las vistas siguen siendo placeholder) — ver doc/plans/2026-07-25-navegacion-admin-tabs.md. El sidebar de Administración pasa de 7 ítems sueltos a 4: Usuarios y Accesos (tabs: Usuarios · Roles y Permisos · Asignación de Roles) y Ubicaciones (tabs: Países · Estaciones/PDV) agrupan 5 de ellos; Configuración y Auditoría quedan sueltas (confirmado con el cliente, no es un olvido). Cada tab sigue siendo su propia ruta con su propio meta.module — Usuarios/Roles y Permisos/Asignación de Roles siguen siendo 3 filas independientes en la matriz L/E/B, el layout de tabs (TabbedSectionLayout.vue) es solo el contenedor visual, nunca gatea permisos por sí solo.

Bug real encontrado y corregido en el camino: firstAllowedRoute() (fallback post-login / ruta bloqueada) iteraba router.getRoutes() asumiendo que ese orden coincidía con la prioridad del array candidates (dashboard primero) — dejó de ser cierto al anidar 5 rutas bajo 2 layouts nuevos, porque getRoutes() ordena por score de matching interno de Vue Router, no por prioridad de sitemap. Un Supervisor sin acceso directo a una tab bloqueada caía en Auditoría en vez de Dashboard. Corregido iterando candidates directamente (con un Map para el lookup por nombre) en vez de confiar en el orden de getRoutes().

M5 — Integración Soterex (resumen — ⛔ parcialmente bloqueado)

SoterexClient (tokenC2P con cache de 43200s), job real del cron de 30' (SendRequest → validación → MTCN secuencial único → ACCEPTED → Notifications; inválidas/duplicadas quedan lockeadas con alerta, §4.1), Cancel + Notifications enganchados a las transiciones de M1. Bloqueos: #2b (cancelación iniciada por Soterex — bloqueante) y #1 (campos fecha de nacimiento / registro de extranjero — el modelo ya los tiene nullable). El bloqueo de credenciales sandbox quedó resuelto por adelantado (ver abajo): el resto de M5 (el job real del cron, el enganche de Cancel/Notifications a las transiciones) sigue pendiente, pero ya no depende de tener credenciales para poder desarrollarse y probarse.

Adelanto 2026-07-25 — Config de Soterex editable + modo mock (fuera de orden, a pedido)

El cliente todavía no compartió credenciales ni URL de sandbox de Soterex. En vez de esperar a M5, se adelantó la pieza de configuración (parte de M4, ABM Configuración) para no bloquear nada:

  • App\Models\SoterexSetting — fila única en DB (no .env): mode (mock/real), base_url, username, password (cast encrypted, nunca se expone por API — solo has_password: bool), notifications_url. Editable por Admin en /admin/configuracion.
  • SoterexClient reescrito para leer SoterexSetting::current() en cada llamada. En modo mock (default), ninguna llamada sale a la red — cada método devuelve una respuesta simulada con la misma forma exacta que la real documentada en doc/site/public/openapi.yaml (tokenC2P, SendRequest con validación de campos requeridos y errores 2000-2015, Cancel, Notifications) — el código que consuma este cliente en el futuro (el job real de M5) no necesita cambiar nada al pasar de mock a real.
  • Endpoint POST /admin/configuracion/soterex/test ("Probar conexión") dispara tokenC2P y muestra la respuesta cruda — sirve igual en mock o real, para que el Admin valide sin tocar código.
  • Las env vars SOTEREX_* de .env ya no son la fuente de verdad — solo siembran la fila inicial la primera vez (SoterexSettingSeeder), útil para cuando staging/producción sí las carguen.
  • 7 tests de feature (permisos, password nunca expuesto, edit sin password no lo borra, mock responde sin red real) + verificación en browser real (guardar, recargar, persiste).

Estrategia de pruebas por módulo

Cada módulo entrega: (a) tests de feature de backend (permisos, happy path, errores), (b) verificación e2e en browser real, y (c) plan de pruebas manual para validación del usuario — el feedback de ese plan gatilla los ajustes antes de arrancar el módulo siguiente.

Bug encontrado en verificación e2e — sidebar resaltaba "Dashboard" en cualquier ruta

AppLayout.vue tenía un solo router-link reusado con active-class para todos los ítems del sidebar. Para el ítem de Dashboard (to="/"), Vue Router marca ese link como "activo" (no exacto) en cualquier ruta hija del layout, porque comparten el mismo registro de ruta padre (path: '/') en el árbol de rutas anidadas — comportamiento documentado de Vue Router para rutas índice, no un bug del framework. Confirmado con evidencia real (classList del DOM en /transacciones y /admin/configuracion, ambos casos con "Dashboard" resaltado a la vez que la ruta real). Fix: para el ítem cuyo to es /, usar exact-active-class en vez de active-class — el resto de los ítems no lo necesitan porque ninguna otra ruta comparte prefijo.

Incidente 2026-07-25 — aislamiento de base de datos en tests (resuelto, 3 intentos)

Al armar el andamiaje de testing (no existía — ni phpunit.xml ni tests/TestCase.php), la primera corrida de RefreshDatabase vació la base de dev (soterex) en vez de una base de test aislada (soterex_test). Sin datos reales en juego (era 100% seed de esta misma sesión, recuperado con php artisan db:seed las 3 veces), pero el bug de aislamiento era real y se repitió dos veces más al intentar arreglarlo sin verificar con evidencia:

  1. Intento 1: DB_DATABASE=soterex_test en .env.testing. Falló: docker-compose.yml inyecta backend/.env como variables de entorno reales del contenedor (env_file), y esas ganan sobre lo que .env.testing intenta cargar (Dotenv nunca pisa una env var que el proceso ya tiene seteada).
  2. Intento 2: mover DB_DATABASE a phpunit.xml (<env>), asumiendo que eso alcanzaba. Falló: PHPUnit's <env> tiene el mismo comportamiento "no pisar" por defecto — hace falta force="true" explícito por cada entrada.
  3. Intento 3: agregar force="true" a cada <env>. Todavía falló: force="true" actualiza $_ENV y getenv(), pero no $_SERVER (poblado una sola vez al arrancar el proceso PHP desde el entorno real de Docker). Laravel's helper env() resuelve priorizando $_SERVER sobre $_ENV — confirmado con un test de diagnóstico (tests/Unit/EnvDebugTest.php, volcado de las 5 fuentes, después borrado). Fix real: agregar el bloque <server> paralelo (mismo force="true") en phpunit.xml.

Quedó un test canario permanente, tests/Unit/DatabaseIsolationTest.php — no usa RefreshDatabase, solo verifica config() y SELECT current_database(). Correrlo primero (--filter=DatabaseIsolationTest) es la forma segura de confirmar el aislamiento antes de dejar correr cualquier test que sí toque el esquema.

Nueva pregunta abierta para M5 2026-07-27 — motivo de cancelación vs. sub_status de Soterex

Surgió al implementar doc/plans/2026-07-25-ajustes-nav-motivo-color.md (combo de motivo de cancelación, CancelReasonSelect.vue): los 3 motivos "de fábrica" del combo (cancelada_remitente, ajuste_beneficiario, ajuste_monto) coinciden a propósito con los 3 sub_status que ya define la API de Soterex para cancelaciones (códigos 1000/1001/1002, doc/site/public/openapi.yaml). El motivo queda auditado como texto libre en TRANSACTION_STATUS_CHANGE.metadata.motivo (TransactionController::cancel()), pero un usuario puede cargar un motivo nuevo sin código de Soterex asociado — y el Cancel API real exige sub_status: {code, message} para poder llamarse.

A definir con el cliente antes de que M5 llame al Cancel real: ¿los motivos nuevos se mandan con un código genérico "otro" (si Soterex tiene uno), o quedan solo como dato interno de auditoría/reporting y la llamada real a Soterex siempre resuelve a uno de los 3 códigos oficiales por detrás (ej. eligiendo el más parecido, o forzando "ajuste_monto" como default genérico)? No bloquea nada de lo ya implementado — M5 sigue bloqueado primero por la pregunta #2b del funcional.

Documentación viva — se actualiza junto con el código, no es un anexo aparte.