Skip to content

Auditoría pre-versión estable — hallazgos y brainstorming

Date: 2026-09-13 Autor: sesión de Claude Code (rol product-owner) a pedido de Carlos Alcance: prefondeo en 3 capas, autenticación y ciclo de vida de usuarios, seeders / datos de demostración, y Dashboard + KPIs. Estado: informe de diagnóstico. Las decisiones que salen de acá viven en ADR-010 y el trabajo en el plan v3.

Cómo leer este documento. Todo lo que dice "verificado" se comprobó ejecutando algo (tests, consultas contra la base de dev del 2026-09-13). Lo que dice "por lectura de código" se dedujo leyendo los archivos citados y no se ejecutó. La distinción importa: varias cosas que parecían bugs resultaron ser datos viejos, y al revés.


0. Resumen para quien no lee el resto

#TemaVeredicto
1El Dashboard no tiene ningún KPI rotoLos 17 tests pasan. Muestra cero porque las transacciones pagadas de la base son de hace 42–55 días. Es un problema de datos, no de código. Sí hay 6 bugs finos reales, pero ninguno explica lo que estás viendo
2El prefondeo cumple 2 de las 4 reglas y le falta entera la tercera capa de controlHolding descuenta principal+comisión ✅ · país y caja descuentan solo principal ✅ · el aviso caja→país existe y funciona ✅ · el aviso país→Holding no existe, y la regla ya está violada hoy en −11.888,90 sin que nadie se entere
3Los datos de demo no envejecenEl seeder siembra fechas relativas al momento de correrlo y nunca se vuelve a correr. Y en una base fresca el sistema queda peor: no se puede pagar ni una transacción
4Falta todo el autoservicio de credencialesNo hay "olvidé mi contraseña", no hay cambio de contraseña, no hay cambio forzado en el primer login. El Admin conoce y conserva la contraseña de todos, para siempre
5Dar de baja a un usuario no lo saca del sistemaactive=false corta el API, pero la cuenta de Firebase sigue viva y su token vigente sigue siendo aceptado hasta 1 h
6Hay una escalada de privilegios abiertaCon permiso de asignar roles, un usuario puede autoasignarse Admin en cualquier país. La protección anti-escalada existe en la matriz de permisos, pero no en la pantalla de al lado

Los puntos 4, 5 y 6 son los que convierten "casi estable" en "todavía no": ninguno se nota en una demo y los tres se notan el día que hay 80 cajeros y alguien renuncia.


1. Prefondeo en tres capas

1.1 Qué pediste y qué hay

Regla pedida (Carlos, 2026-09-13)EstadoEvidencia
La Holding descuenta principal + comisiónya cumplePrefundingService.php:33,46SUM(amount + fee). Test TransactionApiTest.php:599 (1000 − 103,50 = 896,50)
El prefondeo repartido a los países no puede exceder el total de la Holdingno existe nadaCountryPrefundingController::store() no inyecta siquiera PrefundingService (constructor :25-28)
El fondeo de las cajas de un país no puede exceder el prefondeo de ese país⚠️ existe como avisoCashBoxController.php:406-425 (OVER_COUNTRY_PREFUNDING), aviso posterior con HTTP 201
País y caja descuentan solo principalya cumpleCountryPrefundingService.php:33-35 (sin fee) y TransactionController.php:373-375

Las dos reglas que ya se cumplen no son casualidad: están escritas en ADR-007 §1 y tienen tests dedicados (CountryPrefundingApiTest.php:70, TransactionApiTest.php:640).

1.2 El aviso de caja → país funciona (verificado)

Se corrió la suite completa de caja el 2026-09-13:

✓ fondear por encima del prefondeo del pais avisa pero crea el movimiento
✓ fondear dentro del prefondeo del pais no avisa
Tests: 48 passed (184 assertions)

Y el banner ámbar está realmente cableado en el frontend (CajaView.vue:471-489, alimentado en :289-291), con su propio test (CajaView.spec.ts:424). No se pierde al resetear el formulario.

Por qué igual parece que no anda: con los datos de hoy es inalcanzable. Guatemala tiene el país en 94.133 con apenas 10.000 repartidos en cajas, así que para ver el aviso habría que fondear una caja por más de 84.133. Nadie va a tipear eso por accidente.

Tres agujeros reales del aviso, que sí hay que tapar:

  1. AJUSTE positivo es un bypass. CashBoxController::ajuste() (:336-396) mete plata en la caja con el mismo permiso que el fondeo y nunca mira el prefondeo del país. Un ajuste de +5.000 con motivo "Sobrante de arqueo" sobregira al país en silencio.
  2. El aviso no se limpia al cambiar de caja. fondeoWarning sobrevive a resetManualForm() (correcto) pero también a cambiar de caja seleccionada, así que puede quedar colgado un aviso de otra caja.
  3. store() del prefondeo país no tiene transacción ni lock (CountryPrefundingController.php:69-111), a diferencia de pay(), fondeo() y apertura(). Hoy da igual porque no valida nada; en el momento en que tenga un cálculo de techo, dos altas simultáneas leen el mismo número.

1.3 El aviso que falta: país → Holding

CountryPrefundingController::store() crea el asiento validando permisos y monto, y nada más. No existe siquiera la métrica: ninguna consulta del sistema suma country_prefunding_entries a nivel global — los tres usos del modelo filtran por country_id.

Y la regla ya está violada. Medición real contra la base de dev, 2026-09-13:

ConceptoValor
Σ asientos Prefondeo Holding (lo que entró de Soterex)138.100,00
Σ asientos Prefondeo País (lo bajado a todos los países)148.100,00
Σ comisiones de transacciones pagadas1.888,90
Disponible de la Holding para repartir−11.888,90

Se repartió a los países 10.000 más de lo que la Holding recibió nunca, y la app no dijo nada.

Parte de eso es herencia del propio deploy: el backfill de 2026_08_06_120000_e4b3_crear_prefondeo_pais.php:60-67 copió 1:1 los asientos de la Holding a la capa País para que ningún país arrancara negativo. O sea que el día 1 el sistema ya estaba exactamente en el límite, y cualquier asiento posterior lo cruzó.

1.4 La fórmula del disponible (esto es lo que hay que implementar)

El código ya resuelve este mismo problema un nivel más abajo, y conviene copiar el patrón en vez de inventar uno: disponible para repartir = saldo propio − suma de los saldos de los hijos.

F  = Σ prefunding_entries.amount           (lo que Soterex acreditó a la Holding)
D  = Σ country_prefunding_entries.amount   (lo que la Holding bajó a TODOS los países)
Ap = Σ transactions.amount WHERE PAID      (principales pagados)
Fe = Σ transactions.fee    WHERE PAID      (comisiones de lo pagado)

holding.globalBalance()          = F − (Ap + Fe)                      [ya existe]
holding.availableToDistribute()  = globalBalance − Σ_c pais.balance(c)
                                 = [F − Ap − Fe] − [D − Ap]
                                 = F − Fe − D                          [FALTA]

Los principales pagados se cancelan solos, igual que pasa un nivel más abajo con los pagos de caja. Eso importa: significa que un pago no mueve el disponible de la Holding, así que el número es estable y un asiento que era válido ayer no se vuelve inválido hoy solo porque se pagó plata en el mostrador.

La alternativa —comparar contra globalBalance() a secas— es la lectura intuitiva y es la equivocada: el saldo de la Holding baja con cada pago, y usarlo como techo haría que el disponible se achique cada vez que se cobra una transacción, que es justo cuando la plata ya estaba distribuida.

1.5 Cosas colaterales que aparecieron en el mismo pase

  • El saldo de la Holding puede irse a negativo sin ningún énfasis visual.PrefondeoView.vue:94-99 lo pinta siempre del mismo color, mientras PrefondeoPaisView.vue:110-122 sí lo marca en rojo con leyenda. Es deuda que ADR-007:44 dejó anotada por escrito y nunca se pagó.
  • El Dashboard rotula mal el saldo. DashboardView.vue:46 dice "Saldo de prefondeo (País)" pero muestra PrefundingService::balance($country), que es el desglose de la Holding por país — y ese número sí incluye comisiones. Un supervisor puede conciliar contra el banco un número que trae comisión adentro.
  • La capa País no existe en ningún reporte. ReportesController.php:346-369 solo conoce prefunding_entries. Tesorería no tiene export de la única capa contra la que concilia.
  • distributedInCashBoxes() compara monedas distintas (CountryPrefundingService.php:60-68): suma amount_local de las cajas (moneda de pago del país) contra un saldo de país en USD. Hoy no explota porque Guatemala paga en dólares (pago_en_moneda_local=false), pero la bandera es editable y el día que se prenda, el aviso compara quetzales contra dólares.

2. Dashboard y KPIs

2.1 El diagnóstico principal: son los datos (verificado)

Los 17 tests de DashboardApiTest pasan. Y la base de dev, consultada el 2026-09-13 a las 19:14 hora Guatemala, explica todo lo demás:

PAID hoy: 0          PAID ayer: 0
días con PAID en los últimos 30: 0
rango real de last_updated_at de las pagadas: 2026-07-20 .. 2026-08-02   (hace 42 a 55 días)
ACCEPTED pendientes: 95

Cinco de los siete bloques del dashboard cuelgan de last_updated_at dentro de una ventana móvil (hoy / ayer / 30 días / 8 semanas). Con todas las pagadas fuera de esas ventanas, el cero es la respuesta correcta. El único bloque que se ve vivo es "pendientes", porque no filtra por fecha.

No hay nada que arreglar en el código para que esos números vuelvan: hay que arreglar los datos, y que dejen de envejecer (ver §3).

2.2 Bugs finos que sí son reales (por lectura de código)

Ninguno explica los ceros de arriba, pero todos van a morder cuando haya datos de verdad:

  1. El ranking de PDVs esconde plata que el KPI sí cuenta. DashboardController.php:213 filtra whereNotNull('station_id'), mientras el KPI de "pagado hoy" (:60-65) no. Una transacción pagada por un usuario sin PDV resuelto queda con station_id = NULL y produce la pantalla contradictoria: "Pagado hoy US$ 12.500" arriba y "Todavía no hay pagos hoy" abajo. Que es un agujero conocido lo prueba ReportesController.php:224-246, donde Volumen agrega una fila "Sin PDV asignado" con el comentario de que sin eso los totales no reconcilian. El Dashboard nunca recibió ese arreglo.
  2. "Últimos 30 días" significa dos cosas distintas. Dashboard: subDays(29) = 30 días (:115, :137, :166). Reportes: subDays(30) = 31 días (ReportesController.php:393). Las dos pantallas, abiertas el mismo día sin tocar filtros, dan totales distintos.
  3. "Pendientes" significa dos cosas distintas. Dashboard cuenta todos los ACCEPTED del país desde siempre (:54-57); Situación cuenta los del rango, default 31 días (ReportesController.php:319-320). Con seis meses de operación uno dice 412 y el otro 37, y ninguna pantalla aclara cuál es cuál. Agravante: expires_at está en el modelo y no se consulta en ningún lado, así que la bolsa de pendientes crece para siempre.
  4. El ranking incluye PDVs desactivados, Volumen los excluye (:198-201). Desactivar un PDV hace que las dos pantallas dejen de cerrar.
  5. El selector de país ofrece países donde el usuario no puede leer el dashboard.DashboardView.vue:13-14 usa countriesWithAccess(), que no mira el módulo, y arranca en el primero. Un usuario con dashboard en GT y sin dashboard en SV puede recibir un 403 en el primer render, siempre, con el mensaje genérico "No se pudo cargar el dashboard".
  6. Dos ejes temporales en la misma pantalla: el breakdown por estado usa created_at (:168) y todo el resto usa last_updated_at. Una transacción creada hace 40 días y pagada hoy suma al KPI de hoy y no aparece en la barra PAID del breakdown. Está documentado como decisión, pero la UI no lo explica y los números vecinos no cierran.

Menores del mismo pase: now() se recalcula 6 veces por request (:48, :114, :137, :166, :187, :200) → un request que cruce medianoche mezcla días; chart_series descarta en silencio filas con fecha futura (:141, sin cota superior); el gate de la comisión compara el nombre del rol contra el string 'Supervisor' (:231-236), así que renombrar el rol desde el ABM apaga la tarjeta sin ningún error; y PermissionService::assignments() dice en su docblock estar "cacheada por request" y no lo está (PermissionService.php:19-25) → ~5 consultas idénticas con 3 eager loads por cada request de dashboard.

Revisado y descartado (para no gastar tiempo ahí): división por cero en la variación (está protegida), whereDate sobre timestamps (no hay ninguno en el repo), estados inexistentes en el enum, alineación lunes/domingo de las semanas, N+1 en los nombres del ranking. Y no quedan mocks en el Dashboard — lo que quedó viejo es la documentación, que sigue hablando de una "serie de 7 días" que ya no existe (hoy son 30 días y 8 semanas).

2.3 Resto del bug de timezone

El fix de config/database.php:41 no tuvo backfill (el commit lo dice: "alcanza con migrate:fresh"). Cualquier fila escrita antes del 2026-08-02 tiene last_updated_at corrido +6 h, o sea cae en el día siguiente. Y el test de regresión (DatabaseTimezoneTest) cubre cash_movements.created_at pero no transactions.last_updated_at, que es exactamente la columna de la que cuelga el dashboard.


3. Seeders y datos de demostración

3.1 El problema estructural

El seeder siembra fechas relativas al momento de correrlo (TransactionSeeder.php:78-82, now()->subHours(...)) y después nunca se vuelve a correr, porque saltea por MTCN existente (:69). Resultado: el set de datos tiene una vida útil de una semana y después el sistema entero parece roto.

No existe ningún comando de datos de demo, ni migrate:fresh --seed en ningún script del repo (el único migrate --seed está comentado en start-dev.sh:83).

3.2 Lo que hay hoy

24 transacciones, todas de Guatemala, 11 ACCEPTED / 8 PAID / 5 CANCELLED, montos en una progresión lineal de 731,17 en 731,17 (se nota que es sintético apenas mirás la tabla), todas dentro de una ventana de 6,2 días. Cero factories: todo hardcodeado.

Lo que no se siembra: country_prefunding_entries, cash_boxes, cash_sessions, cash_movements, exchange_rates, transaction_documents, y ninguno de los 11 campos del pago de E5a. CountrySeeder tampoco activa modulo_caja ni setea local_currency.

3.3 Una base fresca hoy no permite pagar (bloqueante)

Encadenado, esto produce un entorno nuevo que no sirve para demostrar el flujo principal:

  1. CountrySeeder.php:23 no toca modulo_caja → queda false.
  2. Sin módulo Caja, el pago valida contra el prefondeo del país (TransactionController.php:231-244).
  3. Nadie siembra country_prefunding_entries, y el backfill de la migración corre antes de los seeders, con la tabla origen vacía → inserta 0 filas.
  4. El país arranca en 0 − 18.442,64 = negativo → todo pay() devuelve 422 INSUFFICIENT_PREFUNDING.
  5. Mientras tanto el Dashboard muestra el saldo Holding (54.280,71), o sea un saldo saludable que no se corresponde con el que está bloqueando el pago.

3.4 Qué le falta al set de datos para que la demo se sostenga

  • Más ACCEPTED y en serio (lo que pediste): la bolsa compartida es el corazón de la operación de mostrador y hoy tiene 11 filas.
  • Profundidad temporal: hoy 24 de los 30 días del gráfico están en cero y 7 de las 8 semanas también. Hace falta sembrar ≥ 60 días hacia atrás, con volumen distinto por día.
  • Pagos de hoy y de ayer, sí o sí, o el KPI estrella y su variación no se pueden mostrar.
  • Módulo Caja demostrable: modulo_caja=true + local_currency + cajas + sesiones + movimientos + tipos de cambio.
  • Las tres capas de prefondeo cargadas y coherentes, dejando margen suficiente para que el aviso se pueda disparar tipeando un número razonable (no 84.133).
  • Reparto de PDV realista: hoy el PDV central (GT-CAP-001), que es el único en el alcance de Backoffice, es el que menos transacciones tiene (2). El scoping no se puede demostrar.
  • Datos del pago y documentos en algunas PAID: hoy las 8 pagadas no tienen ni uno de los 11 campos de E5a ni un solo documento, así que el reporte regulatorio sale con las columnas vacías.
  • Auditoría con cuerpo: 5 registros, 3 acciones distintas, ningún entity_id. La pantalla de Auditoría con sus filtros no tiene nada que filtrar.

3.5 Trampas de los seeders actuales (a no repetir en el nuevo)

  • TransactionSeeder explota si DevUserSeeder se salteó (indexa un array vacío sin guard, :88-89), y DevUserSeeder se saltea solo con un warning si no está el emulador (:57-61).
  • PrefundingSeeder deduplica por (país, monto, autor) en vez de por una clave natural: cargar a mano un asiento del mismo monto hace que el seeder crea que ya sembró.
  • SoterexSettingSeeder y AuditLogSeeder son one-shot: cortan si hay cualquier fila. Después del primer login real, el seeder de auditoría queda muerto para siempre.
  • RoleSeeder pisa los permisos editados desde el ABM y corre en la rama no condicional de DatabaseSeeder, o sea también en staging y producción.
  • RolePermission::withoutEvents() (RoleSeeder.php:134) es indispensable y frágil: cualquier seeder nuevo que escriba role_permissions fuera de ese closure reactiva la cascada can_write ⇒ can_read y rompe la pantalla Pago del Backoffice.
  • StagingTestUsersSeeder tiene contraseñas en claro en el repo (Admin123!, Super123!, Backoffice123!) y crea esas cuentas en el Firebase real, y se puede disparar contra producción desde el workflow run-artisan-command.yml.

4. Autenticación, usuarios y roles

Esta es la parte que más lejos está de "muchos usuarios", y la que menos se nota en una demo.

4.1 Nadie puede administrar su propia credencial

FuncionalidadEstado
"Olvidé mi contraseña"no existe (cero usos de sendPasswordResetEmail en el repo)
Cambiar la propia contraseñano existe (cero usos de updatePassword)
Cambio forzado en el primer loginno existe (no hay columna que lo represente en users)
Pantalla de perfilno existe (está confesado en AppLayout.vue:200,327)
Mail de invitación / bienvenida / resetno existe (no hay app/Mail ni app/Notifications en todo el backend)

El único camino para cambiar una contraseña es que un Admin entre a UsuariosView y apriete "Resetear", que genera una contraseña de 20 caracteres y se la muestra en pantalla (UsuariosView.vue:398-416).

Las consecuencias, en orden de gravedad:

  1. No hay autenticación atribuible a una sola persona. El Admin conoce la contraseña de todos los cajeros, para siempre. Eso degrada el valor probatorio de todo el audit_log de caja y de pagos — que es justamente el control que tesorería aceptó a cambio de sacar la firma del Supervisor (ADR-007 §4). Si cualquiera con abm_usuarios:write puede operar como cualquier cajero, el ledger deja de probar quién tocó la plata.
  2. Una cola de tickets en horario de caja. Con 80 cajeros, un olvido de contraseña un sábado a la mañana necesita a un Admin despierto.
  3. La contraseña viaja por donde se pueda. Sin mail transaccional, la entrega es verbal, por WhatsApp o por papel.

4.2 Dar de baja a alguien no lo da de baja

  • active=false corta el API (ResolveApiUser.php:30-35) pero no toca Firebase: no hay disableUser ni revokeRefreshTokens en todo el backend.
  • VerifyFirebaseToken.php:42 verifica con checkIfRevoked = false, así que un ID token ya emitido sigue siendo válido hasta su vencimiento (1 h) aunque la cuenta se revoque.
  • Y el usuario dado de baja que intenta entrar deja la app colgada sin mensaje: el guard hace await meStore.fetch() sin try/catch (router/index.ts:322-324), la promesa se rechaza y la navegación se aborta. Para mostrador es indistinguible de "el sistema se cayó".
  • Ese intento de acceso no queda auditado (ResolveApiUser devuelve 403 mudo).

4.3 Escalada de privilegios abierta

RoleAssignmentController::store() (:32-92) valida unicidad del trío y que los PDVs sean del país. No valida quién otorga qué. Con abm_asignacion_roles:write se puede crear una asignación Admin, para uno mismo, en cualquier país.

Lo llamativo es que la protección sí existe en la pantalla de al lado: RolePermissionController.php:54-80 impide otorgar un permiso que el actor no tiene, lo evalúa después de la cascada y audita el intento (ROLE_PERMISSION_ESCALATION_DENIED). La puerta reforzada tiene al lado una ventana abierta.

4.4 El sistema se puede dejar sin administradores

Nada impide desactivarse a sí mismo (UserController::update:86), borrar la propia última asignación (RoleAssignmentController::destroy:134), ni revocarle abm_roles_permisos / abm_asignacion_roles al rol Admin (revocar no pasa por el chequeo de escalada, y está comentado como intencional: "revocar nunca escala nada"). Cualquiera de las tres deja el sistema sin ningún camino de recuperación por UI.

4.5 La administración no escala a multipaís

UserController::index (:29-33) lista todos los usuarios sin filtrar por allowedCountryIds; lo mismo RoleAssignmentController. Un "Admin de Guatemala" administra usuarios y roles de todos los países. Con un solo país operativo no se nota; con Ecuador en producción, sí.

Sumado a eso: el ABM filtra solo por texto de email (no por estado, país, PDV ni rol), y dos módulos de la matriz no se pueden editar desde ninguna pantallapago y prefondeo_pais no están en la lista hardcodeada de UsuariosView.vue:180-197, aunque sí existen en RoleSeeder y gatean rutas reales. Para tocarlos hay que entrar a la base de datos.

4.6 Lo que la auditoría no ve

Queda registrado mucho (37 puntos de AuditLog::record: usuarios, permisos, asignaciones, caja, transacciones, documentos, exports, login exitoso con IP). No queda registrado: los logins fallidos (ocurren en Firebase, el backend nunca los ve), los accesos denegados por permiso, el logout, el timeout, ni el intento de entrada de un usuario dado de baja. Con eso no se puede detectar fuerza bruta, credenciales compartidas entre cajeros, ni reconstruir quién estuvo adentro durante un descuadre de caja.

Además, LOGIN es autorreportado por el cliente (POST /me/login, fire-and-forget con .catch(() => {})) y esa ruta no tiene throttle, a diferencia de las otras rutas sin gate.

4.7 Los permisos revocados siguen vigentes hasta recargar

/me se pide una sola vez por carga de página y no hay ningún fetch(true) en todo el frontend. Si un Admin te saca un permiso, seguís viendo el módulo en el sidebar y recibís errores genéricos al entrar. El backend sí bloquea — la UI miente.

4.8 Misceláneo del mismo pase

  • Código muerto sensible: getSecondaryIdToken() (frontend/src/services/firebase.ts:64-81) hace login in-band con email+password de un segundo usuario. Quedó sin llamadores cuando E4b.1 sacó al Supervisor de la apertura de caja. Es una superficie de credenciales que ya no sirve para nada.
  • Vista huérfana: RolesPermisosView.vue no tiene ruta desde E3.
  • Timeout por pestaña: el timer es por pestaña (documentado en useInactivityTimer.ts:3-7) pero el signOut de Firebase se propaga entre pestañas → una pestaña olvidada cierra la sesión de la pestaña donde el cajero está trabajando.
  • Alta de usuario no transaccional (UserController.php:56-66): si falla el insert local después de crear en Firebase, queda una cuenta huérfana que bloquea el reintento con EMAIL_EXISTS y no hay forma de repararlo por UI.
  • Sin 2FA para los roles que crean usuarios y mueven permisos.
  • El índice de arquitectura está viejo: doc/architecture/index.md no lista ADR-008 ni ADR-009.

5. Lo que este informe no cubrió

Para que quede explícito qué falta mirar antes de cantar "estable":

  • Integración real con Soterex (M5) — sigue en modo mock y con el punto #2b del análisis funcional abierto (cómo llega una cancelación iniciada por Soterex).
  • E6, la auditoría de seguridad completa del plan v2 — este informe tocó auth y permisos, pero no hizo OWASP, ni IDOR sobre documentos, ni test de carrera sobre los descuentos, ni revisión de infra. Varios hallazgos de §4 son insumo directo para E6, no su reemplazo.
  • Concurrencia real de pagos (doble gasto con requests simultáneos) — se leyeron los locks, no se ejecutó ninguna prueba de carrera.
  • El wizard de pago y los documentos (E5) más allá de lo que tocaba el seeder.
  • Performance con volumen real — todo lo medido fue sobre 124 transacciones.

6. Preguntas que necesitan una decisión tuya

Ninguna bloquea arrancar, pero las cuatro primeras cambian lo que hay que implementar:

  1. El disponible de la Holding, ¿se calcula como F − Fe − D (recomendado en §1.4, estable frente a los pagos) o contra globalBalance() a secas?
  2. El −11.888,90 que ya existe: ¿se corrige con un asiento de ajuste o se deja como está y el aviso simplemente empieza a mostrarlo? (La segunda es más honesta contablemente; la primera deja la demo prolija.)
  3. ¿El aviso también tiene que aparecer en el AJUSTE positivo de caja, o los ajustes de arqueo quedan explícitamente exentos?
  4. Las ACCEPTED vencidas (expires_at): ¿siguen contando como pendientes o el dashboard debe excluirlas?
  5. Para el cambio de contraseña: ¿alcanza con el flujo de Firebase (mail de reset a la casilla del usuario) o los cajeros no tienen mail corporativo confiable y hay que pensar otra cosa (por ejemplo, reset asistido por el Supervisor del PDV)? Esto define si F4 es chico o grande.

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