UX de múltiples cajas, fondeo y ajuste
Análisis Funcional: Módulo Caja — parte 6 de 7. ← Anterior · Índice · Siguiente: Preguntas Abiertas →
Si 04 describe qué hay en cada pantalla, este documento describe por qué está así. Es el documento de criterio: cuando alguien discuta un detalle de la pantalla Caja, la respuesta debería estar acá.
La regla que atraviesa todo: esto mueve plata física. Un error de UI en un ABM se corrige editando una fila; un error de UI acá termina en un cajón que no cuadra al final del turno y en una persona firmando un faltante. Todo lo que sigue está subordinado a eso.
Convención de estados:
- ✅ Implementado — está en
main(E4, E4-multi y E4b, todas mergeadas al 2026-08-06). - 📋 Propuesta de UX, sin implementar — diseño de este propio paquete de documentos, no pedido explícitamente por el cliente ni en ningún alcance cerrado. Sigue sin existir en el código.
- Lo que está marcado como decisión de UX propia lo definí acá para que sea implementable, y conviene que el cliente lo confirme — está listado al final.
Todo respeta el sistema de diseño vigente (UI design system, rebrand): Vue 3 + Naive UI, dark mode real por clase, íconos Lucide, tabular-nums-financial en cifras, y la separación estricta entre la paleta de marca (cis-accent naranja, acciones y navegación) y la paleta semántica (status-*, alert-*, estados y alertas). El naranja de marca nunca se usa para comunicar un problema.
5.1 Múltiples cajas por sucursal
5.1.1 Cómo se elige una caja
✅ La pantalla arranca pidiendo GET /caja/disponibles, que devuelve todas las cajas de todas las sucursales donde el usuario tiene caja:read (CashBoxController::disponibles(), :49-88), y se bifurca:
| Cajas disponibles | Qué pasa | Por qué |
|---|---|---|
| 0 | Mensaje de estado vacío | No hay nada que operar |
| 1 | Auto-selección, directo a la vista operativa (CajaView.vue:71-72) | El caso común es una caja por sucursal. Obligar a elegir entre una sola opción es fricción pura |
| 2 o más | Selector explícito, sin preseleccionar ninguna (CajaView.vue:31) | Elegir mal acá significa que la plata sale del cajón equivocado |
5.1.2 Por qué la elección no se recuerda
✅ Es la decisión #2 del diseño de múltiples cajas y es deliberadamente incómoda. El argumento:
- Una caja física es un cajón con llave que alguien tiene en la mano. Que la app recuerde "vos sos la Caja 2" es una afirmación sobre el mundo físico que la app no puede verificar.
- El costo de recordar mal es asimétrico: recordar bien ahorra un click; recordar mal manda un pago a la caja de otra persona.
- Los turnos rotan. La misma persona puede estar en la Caja 1 a la mañana y en la Caja 3 a la tarde, y el sistema no tiene forma de enterarse del cambio.
De ahí que el selector no tenga "recordar mi elección", ni orden por "última usada", ni preselección. Cada entrada a /caja con más de una caja disponible vuelve a preguntar (test: CajaView.spec.ts:132-144).
El costo de esa decisión se compensa en dos lugares: la vista operativa siempre rotula qué caja se está operando (CENTRAL · Caja 1, CajaView.vue:393) y el botón "Cambiar de caja" vuelve al selector sin recargar la página, refrescando saldos de paso (CajaView.vue:92-94,394-400, test en CajaView.spec.ts:240-256).
5.1.3 Qué pasa si el operador ya tiene una caja abierta y entra a otra
Cambió de fondo desde la primera versión de este documento. Hasta el 2026-08-05, "nada impedía" que la misma persona tuviera dos cajas abiertas a la vez, y applyCashBoxDiscount resolvía la caja con un ->first() sin ordenar — de cuál salía la plata quedaba librado al orden que devolviera la base. Las dos cosas se corrigieron el 2026-08-06 (06, pregunta #4):
- ✅ Ya no se puede tener dos cajas abiertas a la vez.
USER_ALREADY_HAS_OPEN_CASH_BOX(409) más el índice únicocash_sessions_one_open_per_user(backend/database/migrations/2026_08_06_130000_e4b_una_sola_caja_abierta_por_usuario.php:30) lo garantizan a nivel de aplicación y de base. Ver 3.4. - ✅
applyCashBoxDiscountya ordena explícitamente (orderByDesc('opened_at'),TransactionController.php:328) como defensa en profundidad para las sesiones que hubieran quedado abiertas de a dos antes de esa regla.
Lo que no cambió: el selector de /caja sigue sin distinguir "abierta por vos" de "abierta por otro" — la respuesta de /caja/disponibles trae la sesión entera, incluido opened_by_user_id (frontend/src/services/caja.ts:9), pero la tarjeta no lo usa (CajaView.vue:368-387). Con la regla de una sola caja por usuario ya forzada, el riesgo práctico es menor que antes —ya no se puede abrir una segunda caja propia sin darse cuenta—, pero un operador con dos sucursales en su alcance sigue viendo dos badges verdes idénticos, uno de los cuales podría ser la caja de un compañero.
📋 Propuesta de UX, sin implementar — distinguir en el selector la caja propia. Tres badges en vez de dos:
Estado de la caja Badge Token de color Abierta por el usuario actual Abierta por vosstatus-successAbierta por otra persona Abierta por otro usuarioalert-warningCerrada Caja cerradastatus-cancelledEl dato ya viaja en la respuesta; es comparar
session.opened_by_user_idcon el usuario de/me.
5.1.4 De qué caja sale la plata al pagar
Sigue siendo uno de los puntos más confusos del módulo:
La transacción se atribuye a un PDV. La plata sale de la caja que abrió el operador. Dentro de un mismo país, no se valida que coincidan.
$resolvedStationId (a qué PDV se imputa la transacción, TransactionController.php:247) y la caja que se descuenta (applyCashBoxDiscount(), :319-402) se calculan por caminos distintos y no se comparan entre sí — aunque desde el 2026-08-05 sí se valida que la caja sea del mismo país que la transacción (CASH_BOX_COUNTRY_MISMATCH, :342-355, ver 2.5). Un operador con acceso a dos sucursales del mismo país puede, en teoría, tener abierta la caja de la sucursal A y pagar una transacción imputada a la sucursal B: el reporte de Transacciones va a decir "B" y el cajón que queda con menos plata es el de "A".
Fue una decisión consciente ("no pedido, no se agrega esa validación cruzada", §2.3 del diseño técnico) y este documento no la reabre para el caso dentro de un país. Lo que sí es responsabilidad de la UI es que nadie se entere de esto por un descuadre.
📋 Propuesta de UX, sin implementar — hacer visible la atribución en los dos extremos:
En
/caja, sobre la tarjeta de saldo, cuando la caja está abierta por el usuario actual:CENTRAL · Caja 1 Cambiar de caja 12.500,00 GTQ ● Abierta por vos desde las 08:12 Los pagos que hagas ahora descuentan de esta caja.En
/pago, un indicador permanente arriba del buscador, cargado de/caja/disponibles:┌────────────────────────────────────────────────────────────┐ │ 🏦 Cobrando desde CENTRAL · Caja 1 — saldo 12.500,00 GTQ │ └────────────────────────────────────────────────────────────┘Y sin caja abierta a nombre del usuario, el mismo lugar muestra el bloqueo antes de que lo descubra fallando un pago:
┌────────────────────────────────────────────────────────────┐ │ ⚠ No tenés ninguna caja abierta a tu nombre. │ │ Vas a poder buscar transacciones, pero no cobrarlas. │ │ [ Ir a Caja ] │ └────────────────────────────────────────────────────────────┘Con ese indicador, el botón "Pagar" del resultado se muestra deshabilitado con tooltip "Abrí una caja para poder cobrar", en vez de fallar después de la confirmación. Los errores del backend (
NO_CASH_BOX_OPEN_FOR_USER,CASH_BOX_COUNTRY_MISMATCH) se mantienen igual: son el cinturón de seguridad, no la primera línea de comunicación.Sigue sin estar en ningún alcance escrito. La anoto acá porque, con C5 implementado (el pago con módulo Caja ya no valida prefondeo), la caja quedó como el único freno de saldo en el pago, y esa dependencia sigue siendo invisible en la pantalla donde se cobra (ver 04, §4.3).
5.2 Fondeo vs. Ajuste: el mismo formulario, conceptos opuestos
5.2.1 Por qué comparten formulario
✅ Mecánicamente son idénticos: monto + motivo, mismo permiso (fondeo_caja:write), misma precondición (sesión abierta). Por eso comparten un formulario con un toggle segmentado arriba (CajaView.vue:439-457,442-457) — la duplicación visual de dos bloques casi iguales era ruido, no información.
Conceptualmente son opuestos, y en el backend siguen siendo dos endpoints y dos tipos de movimiento distintos, cada uno con su propio catálogo cerrado de motivos (ver 5.3):
| Fondeo | Ajuste | |
|---|---|---|
| Qué afirma | Entró plata nueva al cajón | El registro estaba mal, la plata física dice otra cosa |
| Signo | Siempre positivo (min:0.01, CashBoxController.php:277) | Positivo o negativo, nunca cero (not_in:0, CashBoxController.php:340) |
| Efecto sobre el saldo | Sube | Sube o baja |
| Límite | Ninguno, salvo el aviso de sobredistribución del país (5.4) | No puede dejar la caja negativa (ADJUSTMENT_WOULD_GO_NEGATIVE, CashBoxController.php:360-365) |
| Quién lo mira después | Tesorería, como distribución | Tesorería, como incidente a revisar |
Esa última fila es la que importa: un fondeo es rutina, un ajuste es una excepción que alguien va a tener que explicar. Que la UI los confunda no es un problema estético — contamina el dato con el que se detectan faltantes.
5.2.2 Cómo los diferencia la UI
✅ Lo que ya hace hoy:
- Toggle segmentado de dos opciones, el activo con fondo
cis-accent(CajaView.vue:442-457). - Cambiar de modo limpia monto, motivo y error (
setManualType,CajaView.vue:256-262, test enCajaView.spec.ts:526-539). - Placeholder distinto:
Monto a fondearvs.Monto del ajuste(CajaView.vue:467). - Texto de ayuda solo en Ajuste: "Corrige el saldo contra un arqueo — positivo si sobra, negativo si falta." (
CajaView.vue:459-461). - Verbo distinto en el botón:
Fondear/Ajustar(CajaView.vue:514). - Catálogos de motivos independientes por tipo (
manualMotivoOptions,CajaView.vue:242-244), cada uno cerrado (ver 5.3). - Mensajes de validación distintos: "Ingresá un monto válido." para fondeo, "Ingresá un monto distinto de cero (positivo si sobra, negativo si falta)." para ajuste (
CajaView.vue:269-272).
Decisión de UX propia, sin implementar — dos refuerzos que faltan y son baratos:
El signo, visible antes de confirmar. El campo de monto de Ajuste acepta
-120tipeado a mano, y un-que no se puso es indistinguible de uno que sí. Ver 5.3.3.Confirmación solo para el ajuste que resta. El formulario de ajuste (
CajaView.vue:264-308) sigue sin ningúnConfirmDialog:submitManual()postea directo apenas se valida el formulario, sea fondeo, ajuste positivo o ajuste negativo. Un ajuste negativo afirma que falta plata, y esa afirmación tiene consecuencias sobre una persona. Merece unConfirmDialogcon el número escrito en palabras del negocio:Registrar un faltante Vas a registrar un faltante de 1.200,00 GTQ en CENTRAL · Caja 1. El saldo de la caja pasa de 12.500,00 a 11.300,00 GTQ. Este movimiento no se puede borrar ni editar — queda en el historial a tu nombre. [ Cancelar ] [ Registrar faltante ]
El fondeo y el ajuste positivo no llevan confirmación: agregar un diálogo a la operación rutinaria entrena a la gente a clickear "Confirmar" sin leer.
5.3 Catálogo cerrado de motivos
✅ Implementado (E4b.1, mergeado 2026-08-06). Lo que sigue describe el catálogo tal como corre hoy; solo 5.3.3 (el signo derivado del motivo) y la mitad de 5.3.4 (el filtro en pantalla) siguen siendo propuestas sin implementar.
5.3.1 El problema que resolvió
Hasta el 2026-08-05 el motivo era un NSelect en modo filterable + tag: elegís uno usado antes o escribís uno nuevo, que se mandaba tal cual, y la lista se derivaba de un SELECT DISTINCT motivo sobre los movimientos ya cargados. El resultado, en palabras del cliente en la reunión: uno escribe "blindado" y otro "transferencia de dinero" para exactamente lo mismo. Se decidió lista cerrada, y no un ABM de motivos: un ABM reintroduce la divergencia que se está tratando de eliminar (alternativa evaluada y descartada en ADR-007, "Alternatives Considered").
5.3.2 El catálogo
| Fondeo | Ajuste |
|---|---|
Fondeo inicial | Faltante de arqueo |
Blindado | Sobrante de arqueo |
Transferencia financiero | Otro |
Otro |
Constantes en CashMovement::MOTIVOS_FONDEO / MOTIVOS_AJUSTE / MOTIVO_OTRO (backend/app/Models/CashMovement.php:30-50), validadas server-side con Rule::in (CashBoxController::validateMontoYMotivo(), :472-495). Blindado queda solo en fondeo: un blindado siempre suma. Si tesorería lo quiere también en ajustes, es agregar una constante — ver 06, pregunta #2.
Comportamiento del control (CajaView.vue:494-507):
NSelectcerrado: sintag, sinfilterable.- Sin opción preseleccionada.
- Elegir
Otrodespliega, debajo del select, un campo de texto obligatorio (motivo_detalle,needsMotivoDetalle,CajaView.vue:247,278-281,500-507). - Cambiar de
Otroa otro motivo limpia el detalle; cambiar de tipo (Fondeo↔Ajuste) limpia todo (resetManualForm,:249-254).
Textos de validación (inline, CajaView.vue:508):
| Situación | Texto |
|---|---|
| Sin motivo elegido | Elegí un motivo. (:274-277) |
Otro sin detalle | Con el motivo "Otro" hace falta una descripción. (:278-281) |
| Motivo fuera del catálogo (422 del backend) | Mensaje literal del backend: El motivo tiene que ser uno de: ... (CashBoxController.php:483-486) |
Los motivos libres ya cargados antes del 2026-08-06 se migraron: los que coincidían con una entrada del catálogo se normalizaron, el resto pasó a Otro conservando el texto original en motivo_detalle (backend/database/migrations/2026_08_06_100000_e4b1_caja_sin_supervisor_y_motivos_cerrados.php). En el listado se ven como cualquier otro Otro — no llevan marca de "migrado".
5.3.3 El motivo determina el signo del ajuste
📋 Propuesta de UX, sin implementar. El catálogo cerrado haría posible algo que hoy sigue sin existir: con Faltante de arqueo y Sobrante de arqueo como opciones explícitas, el signo podría dejar de ser algo que el operador tipea. Hoy MoneyInput en modo Ajuste acepta el signo a mano (CajaView.vue:463-469, allow-negative cuando manualType === 'AJUSTE') — la propuesta de abajo no se implementó:
| Motivo elegido | Campo de monto | Signo enviado |
|---|---|---|
Faltante de arqueo | Magnitud positiva, prefijo visual − fijo a la izquierda | Negativo |
Sobrante de arqueo | Magnitud positiva, prefijo visual + fijo | Positivo |
Otro | Selector explícito de dirección: ( ) Falta plata ( ) Sobra plata | Según lo elegido |
[ Fondeo ][ Ajuste ]
Corrige el saldo contra un arqueo.
Motivo [ Faltante de arqueo ▾ ]
Monto [ − 1.200,00 ] GTQ
El saldo pasa de 12.500,00 a 11.300,00 GTQ.
[ Registrar faltante ]Elimina de raíz el error de tipear 120 cuando era -120 (y su inverso, que es peor: un faltante cargado como sobrante infla el saldo registrado). El precio es que el modo Ajuste pediría elegir el motivo antes que el monto — hoy el orden es siempre monto primero, en los dos modos (CajaView.vue:463-499).
5.3.4 El motivo en el listado y en el filtro
Estado mixto: la mitad de lo que pedía C9 se implementó, la otra mitad no.
✅ En el listado de /caja el motivo se muestra bajo el tipo, atenuado, con el mismo criterio que el TC y el MTCN de los pagos, y con el formato Otro — <detalle> cuando corresponde (CajaView.vue:583-585).
📋 Sin implementar: el filtro en pantalla y el export filtrado. El backend sí soporta filtrar /caja/{cashBox}/movimientos por type y motivo (CashBoxController::validateMovementFilters(), :436-442; frontend/src/services/caja.ts:71-93 ya tipa MovementFilters y arma el query string), pero CajaView.vue nunca los usa: no hay selects de "Tipo"/"Motivo" en la pantalla, ni se pasan filtros al exportar (loadMovements(), CajaView.vue:121-139, sin parámetro de filtros). El backend está listo; falta conectarlo. Ver 04, Hallazgos y 06, pregunta #2 para el caso más visible de esta falta —el reporte de Cajas tampoco lo tiene.
5.4 La alerta que avisa y no bloquea
✅ Implementado. Fondear una caja por encima de lo que el país tiene disponible para repartir avisa sin impedir — exactamente lo que pidió Diego: "como una alerta más que un validador" (ADR-007 §3).
5.4.1 El contrato real, y la contradicción que se resolvió
La versión anterior de este documento anotaba una contradicción sin resolver entre el ADR (aviso posterior, después de crear el movimiento) y el plan E4b (confirmación previa, "¿Confirmar igual?"). Se implementó la variante posterior, la única compatible con el contrato del backend:
CashBoxController::fondeo()responde 201 (no 200 — corregir esa cifra en cualquier lectura anterior de este documento), crea el movimiento siempre, y agrega unwarningopcional calculado después de insertar (:273-325, el cálculo vive enoverDistributionWarning(),:406-425).- La condición no es "el fondeo excede el saldo del país" sino "el disponible para distribuir queda negativo" — saldo del país menos lo ya repartido en sus cajas (
CountryPrefundingService::availableToDistribute(),:78-81). Esto responde por sí solo la pregunta #9 de la versión anterior de este documento: el umbral correcto (disponible, no saldo total) es el que se implementó. - Código
OVER_COUNTRY_PREFUNDING.
5.4.2 El patrón implementado: banner posterior, persistente, no modal
CajaView.vue:470-490. Tres propiedades, las tres presentes:
- No es un modal. Es un
<div>inline dentro de la tarjeta de saldo. - No es una notificación flotante. Cuando hay warning, la notificación de éxito "Caja fondeada" no se dispara (
submitManual(),:288-294): el banner ya confirma que se registró. - Es un banner persistente con ícono
TriangleAlert, que se descarta con un botón×(fondeoWarning.value = null,:485) y no tiene auto-dismiss.
┌─────────────────────────────────────────────────────────────┐
│ ⚠ Fondeaste por encima del prefondeo del país [×] │
│ │
│ Este fondeo deja al país con más plata repartida en │
│ cajas que la que tiene disponible. Avisale a tesorería │
│ para que regularice el saldo. │
└─────────────────────────────────────────────────────────────┘Diferencia con el diseño original de este documento: el título es fijo ("Fondeaste por encima del prefondeo del país", CajaView.vue:477-479) y el cuerpo es el mensaje literal que manda el backend (el binding de fondeoWarning.message, :480) — no interpola el monto fondeado, el país ni el saldo resultante en el texto que ve el usuario, aunque el backend sí devuelve available_to_distribute en la respuesta (frontend/src/services/caja.ts:65-69). La variante "sin número, para quien no tiene permiso de ver el prefondeo del país" que proponía la versión anterior de este documento tampoco se implementó — el mensaje es el mismo para todos los que reciben el warning.
📋 Propuesta de UX, sin implementar — interpolar el monto/país/saldo en el cuerpo del banner como proponía originalmente este documento, y variar el texto según si el usuario tiene o no prefondeo_pais:read.
5.4.3 Cómo se distingue de un error que sí bloquea
| Alerta que no bloquea | Error que bloquea | |
|---|---|---|
| Ejemplo | Fondeo por encima del disponible del país | ADJUSTMENT_WOULD_GO_NEGATIVE, CASH_SESSION_CLOSED |
| HTTP | 201 | 4xx |
| ¿Pasó algo? | Sí, el movimiento existe | No, no se guardó nada |
| Color | alert-warning ámbar | alert-danger rojo |
| Ícono | TriangleAlert | Sin ícono (línea de texto simple) |
| Forma | Banner con título, cuerpo y × (CajaView.vue:470-490) | Línea de texto corta bajo el formulario (:508) |
| Ubicación | Tarjeta de saldo, arriba del bloque de movimiento manual | Inmediatamente debajo del formulario |
| Formulario | Se limpia (resetManualForm(), :299) | Conserva lo cargado (:302-304) |
La regla de oro sigue valiendo: el tiempo verbal. Si el texto está en pasado, la plata se movió. Y nunca, en ninguno de los dos casos, se usa el naranja de marca (cis-accent).
5.5 El saldo negativo del Prefondeo País no es un error
✅ Implementado, en PrefondeoPaisView.vue, con un diseño más simple que el propuesto originalmente en esta sección.
5.5.1 Por qué puede pasar
Con la validación de prefondeo sacada del pago (2.6), cada pago descuenta el principal del Prefondeo País sin chequearlo primero, así que el saldo puede quedar abajo de cero. ADR-007 §2 lo dice sin ambigüedad: "un saldo negativo es información contable válida, no un estado de error". Refleja la deuda real con la Holding.
5.5.2 Cómo se muestra hoy
Saldo del país
−1.200,00 USD
En rojo porque se pagó más de lo que la Holding le mandó al país. Es la deuda pendiente,
no un error — la operación no se frena por esto.| Elemento | Implementado | Propuesta original de esta sección (no adoptada) |
|---|---|---|
| Formato del número | Signo menos adelante, con separadores (PrefondeoPaisView.vue:108-115) | Igual |
| Color | alert-danger cuando < 0 (:110-112) | Igual |
Badge nombrado (Sobregiro) | No existe | Badge ámbar con la palabra "Sobregiro" |
| Leyenda explicativa | Un párrafo de dos líneas, siempre visible mientras el saldo es negativo (:117-122) | Igual en espíritu, texto distinto |
| Cero | 0,00, sin color ni leyenda | Igual |
Lo que se mantuvo sin cambios respecto de la propuesta original:
- No se clampea a
0,00ni se muestra—. - No se deshabilita ninguna acción por saldo negativo: ni pagar, ni fondear, ni abrir caja.
- No se usa la palabra "error", ni un ícono de error, ni un banner rojo de página completa.
- El saldo negativo no dispara notificaciones cada vez que se carga la pantalla.
- La Holding y la caja operativa no pueden mostrar un negativo como estado válido — la caja, porque
ADJUSTMENT_WOULD_GO_NEGATIVEy la resta de saldo antes de unPAGOlo impiden a nivel de aplicación; la Holding, en cambio, sí puede quedar negativa en los hechos y nada lo evita ni lo avisa (ADR-007 §1, nota ⑴) — es la asimetría que documenta ADR-010 (Status: Proposed, sin implementar): hay aviso hacia abajo (país→caja, 5.4) pero nada hacia arriba (Holding). Cualquier lectura de este documento que asuma "la Holding nunca es negativa" está describiendo la intención, no el comportamiento real.
📋 Propuesta de UX, sin implementar — el badge
Sobregiroque nombra el estado sigue siendo una mejora razonable: hoy el color rojo sin ninguna etiqueta puede leerse como un bug a primera vista, sobre todo para quien lo ve por primera vez.
Este saldo, sin embargo, no alcanza por sí solo para que el tesorero sepa cuánta plata le queda para repartir a las cajas — ese número sí existe hoy, ver 5.7.
5.6 Apertura con saldo heredado
✅ Implementado (E4b.1), con un modal más simple que el diseño original de esta sección.
5.6.1 Qué cambió
| Antes de E4b (E4/E4-multi) | ✅ Hoy | |
|---|---|---|
| Pasos del modal | 2 (monto → Supervisor) | 1 (CajaView.vue:635-672) |
| Saldo inicial | Input numérico que tipeaba el operador | Dato de solo lectura, el saldo actual de la caja (:642, que es literalmente lo heredado del cierre anterior) |
| Autorización | Email + contraseña de un Supervisor, reautenticado in-band | Ninguna: la abre el operador con su propia sesión |
El fundamento de que el monto no sea editable —ni siquiera como sugerencia pre-cargada— está en la frase de Teresa: "el que comience con el mismo saldo final del día anterior le da más seguridad al manejo del dinero". Test: CajaView.spec.ts:278-303 ("apertura: confirma sin supervisor ni monto, y muestra el saldo heredado").
5.6.2 El modal implementado
┌────────────────────────────────┐
│ Abrir caja │
│ │
│ Saldo inicial │
│ 12.500,00 │
│ Es el saldo con el que quedó │
│ la caja al cerrar la última │
│ vez. │
│ │
│ Si la plata que hay en la caja │
│ no coincide con este monto, │
│ abrí igual y registrá la │
│ diferencia como un ajuste. Si │
│ entra plata nueva, cargala │
│ como fondeo. │
│ │
│ [ Abrir caja ] [ Cancelar ] │
└────────────────────────────────┘(CajaView.vue:637-652.) Comparado con el diseño original de esta sección, lo implementado es más austero en tres puntos concretos:
- No distingue la primera apertura de una caja (saldo
0,00real) de una apertura normal con saldo heredado — el texto es el mismo en los dos casos. El diseño original proponía un texto distinto ("Esta caja se abre por primera vez"). - No muestra la procedencia del saldo — ni la fecha ni el usuario del cierre anterior (
Heredado del cierre anterior — 04/08 a las 19:42, por backoffice@cislatam.test, como proponía la versión anterior de este documento). El backend no expone esos dos datos junto al saldo heredado hoy. - El resto de las reglas sí se cumplió: el monto es tipografía de cifra sin borde de input (
:641), un solo botón primario, estado de cargaAbriendo...(:662), y errores del backend (CASH_ALREADY_OPEN,USER_ALREADY_HAS_OPEN_CASH_BOX) inline dentro del modal (:654).
📋 Propuesta de UX, sin implementar — agregar la procedencia (fecha/hora + email de quien cerró) exige que el backend la exponga junto al saldo heredado; hoy
GET /caja/disponiblesno la incluye.
5.6.3 El camino cuando la plata no coincide
📋 Propuesta de UX, sin implementar. El texto del modal explica el camino ("registrá la diferencia como un ajuste"), pero no hay ningún atajo de un click después de abrir: la propuesta original de esta sección —un nudge de una sola vez con un link directo al modo Ajuste, que se descarta al primer movimiento o al recargar— no se implementó. Abrir el bloque "Movimiento manual" en modo Ajuste sigue siendo un paso manual del operador.
5.6.4 El texto del cierre
✅ Implementado, con texto distinto al que proponía este documento. El ConfirmDialog de cierre dice hoy: "Se va a registrar el saldo final de la caja y no vas a poder cargar más movimientos hasta volver a abrirla. La plata queda en la caja: la próxima apertura arranca con ese mismo saldo." (CajaView.vue:677) — ya no menciona un retiro ni que la caja "va a quedar en cero", que era exactamente el problema que esta sección señalaba sobre el texto viejo.
Diferencia con la propuesta original: el texto implementado es fijo, no interpola el monto exacto ni el nombre de la caja (CENTRAL · Caja 1 con 12.500,00 GTQ, como proponía la versión anterior de este documento). Mostrar el monto antes de confirmar seguiría siendo útil como última oportunidad de detectar un descuadre, pero no es indispensable ya que el saldo actual está visible en la misma pantalla, justo arriba del botón "Cerrar caja".
📋 Propuesta de UX, sin implementar — interpolar el monto y el nombre de la caja en el cuerpo del diálogo.
5.7 El circuito Holding, País y Caja contado en la UI
Esta sección resuelve, parcialmente, el problema que más le costó entender a tesorería en la reunión: el saldo del país no responde por sí solo la pregunta que el tesorero se hace todos los días. El desglose (5.7.2) y el alta de asientos (5.7.3) están implementados; el bloque que conecta visualmente las tres capas (5.7.4, "Circuito de fondos") no.
5.7.1 El problema
ADR-007 §1 (C10) fija que mover plata del país a una caja no descuenta el prefondeo del país: es distribución banco→cajón, no gasto. El saldo del país solo baja cuando se paga una transacción.
No es una hipótesis: Diego Sánchez (tesorería, CIS-EC) lo verbalizó varias veces en la reunión del 2026-08-05 tratando de entender el modelo, y llegó solo a la conclusión correcta:
"El prefondeo país debería seguir siendo 100.000, ¿verdad? Porque aún no me he gastado nada. Entonces en ese sentido lo que cambia es la distribución. Ahora tengo 80 en el banco y tengo 20.000 en cajas."
Y Teresa Ortiz, desde el otro lado, preguntándole a Carlos si el sistema registra el envío de plata del país a las cajas: "yo le mando 2000 USD a una caja, entonces yo le bajo de mi saldo — no se va a bajar del saldo". Carlos: "es que no tengo eso".
Nota sobre el modelo: no hay ni hay un ledger de "distribución país→caja". Teresa lo descartó en la misma reunión — "no va a haber un lugar donde se pongan los blindados; el blindado va a estar cuando llegue a la caja". El único registro de que la plata salió del país es el
FONDEOque se carga en la caja que la recibe. El número que faltaba se deriva, no se registra:CountryPrefundingService::distributedInCashBoxes()(:50-71) suma los saldos actuales de las cajas del país. No hizo falta ninguna tabla nueva para esto.
5.7.2 El desglose del saldo del país en tres números
✅ Implementado, en PrefondeoPaisView.vue (ruta /prefondeo-pais, ver 04, §4.4.2):
| Rótulo exacto | Qué es | Cómo se calcula | Cita |
|---|---|---|---|
| Saldo del país | El total contable | CountryPrefundingService::balance() | backend/app/Services/CountryPrefundingService.php:28-38 |
| Distribuido en cajas | La plata que ya salió a los cajones y todavía no se pagó | distributedInCashBoxes() | :50-71 |
| Disponible para distribuir | Lo que queda para mandar a una caja — también puede ser negativo | balance() − distributedInCashBoxes() | :78-81 |
PREFONDEO PAÍS — GUATEMALA
100.000,00 USD
Saldo del país
├─ Distribuido en cajas 40.000,00 USD
└─ Disponible para distribuir 60.000,00 USD
Repartir plata a una caja no baja el saldo del país — recién
baja cuando se paga una transacción.PrefondeoPaisView.vue:107-145, el conector visual ├─ / └─ que hace explícito que los dos números derivados "salen" del de arriba sí se implementó tal como se diseñó originalmente.
Diferencias con el diseño original de esta sección (más simple, no más pobre — cubre el mismo contenido con menos elementos):
- Sin rótulo
[ Ver ]hacia Reportes → Cajas junto a "Distribuido en cajas". - Sin íconos
Wallet/Banknotejunto a cada número. - Sin badge nombrado cuando "Disponible para distribuir" da negativo (posible: las cajas pueden tener más plata que la que el país tiene contablemente, porque el país admite sobregiro y las cajas se fondean sin bloqueo) — solo color rojo sobre el número y una leyenda de texto (
PrefondeoPaisView.vue:132-143), sin la palabra "Sobredistribuido" en ningún lado.
📋 Propuesta de UX, sin implementar — agregar el link
[Ver], los íconos, y un badge que nombre el estado de sobredistribución de la misma manera que se propuso para el saldo negativo del país en 5.5.
Sobre los rótulos: se conservó Disponible para distribuir en vez de En banco — el sistema no concilia cuentas bancarias y no puede sostener esa afirmación sobre el mundo físico.
5.7.3 Alta de un asiento de Prefondeo País
✅ Implementado (PrefondeoPaisView.vue:147-171), carga manual por tesorería, monto + motivo opcional en texto libre (no el catálogo cerrado de Caja: es de otro dominio contable).
NUEVO ASIENTO
Monto [ 50.000,00 ] USD
Motivo [ Referencia de la transferencia (opcional) ]
[ Cargar asiento ]Diferencia con el diseño original de esta sección: el alta no pide confirmación antes de guardar (submit(), :56-78) — la propuesta original especificaba un ConfirmDialog con el monto en el cuerpo, igual criterio que el resto de lo que mueve plata. Se implementó con el mismo criterio que ya tenía el Prefondeo Holding (PrefondeoView.vue:110-116, tampoco confirma): un dígito de más se corrige con otro asiento.
📋 Propuesta de UX, sin implementar — el
ConfirmDialogantes de cargar el asiento, si tesorería no carga muchos asientos por día como para que la confirmación moleste.
Vínculo con el asiento de la Holding que lo originó: sigue sin existir.country_prefunding_entries no referencia a prefunding_entries — son dos capas contables independientes y la bajada de plata de la Holding al país no deja rastro cruzado. Ver 06, pregunta #11.
5.7.4 El circuito completo, sin tres pestañas
📋 Propuesta de UX, sin implementar. Hoy son dos pantallas de prefondeo (Holding y País, sin tabs) más la de Caja, y el modelo mental sigue a cargo del usuario — que es exactamente lo que le pasó a Diego en la reunión, con Carlos compartiendo pantalla y explicándole. Ninguna pantalla conecta hoy las tres capas de una sola pasada.
No propongo un dashboard nuevo. La confusión no aparece cuando alguien va a "ver los saldos", aparece cuando está parado en una de las tres capas y no sabe cómo se relaciona con las otras dos.
Propuesta: un bloque de contexto "Circuito de fondos", un solo componente compartido, presente en las tres pantallas, que muestra las tres capas con su saldo y resalta la capa actual.
┌─ CIRCUITO DE FONDOS ─────────────────────────────────────────────────────┐
│ │
│ Holding → País · Guatemala → Cajas │
│ 1.240.000,00 USD 100.000,00 USD 40.000,00 USD │
│ principal + comisión solo principales 3 cajas │
│ ▲ estás acá │
└──────────────────────────────────────────────────────────────────────────┘| Regla | Definición |
|---|---|
| Ubicación | Arriba del contenido, colapsado a una línea por defecto en /caja y expandido en las pantallas de Prefondeo |
| Capa actual | Resaltada con el borde izquierdo cis-accent |
| Permisos | Cada capa se muestra solo si el usuario puede leerla — si queda una sola capa visible, no se renderiza |
| Navegación | Cada capa visible es un link a su pantalla — la única navegación lateral entre capas que existiría |
| Costo | Una request por capa legible |
El circuito que el bloque representaría, para que quede escrito una sola vez:
La flecha punteada es la clave de todo: la distribución país→caja no deja registro en la capa del país.
5.7.5 Con este desglose, sigue haciendo falta la alerta de fondeo
Sí, y las dos ya conviven. No son redundantes:
- Están en pantallas distintas y las miran personas distintas. El desglose vive en Prefondeo País (
prefondeo_pais:read), que mira tesorería. El fondeo se hace en/caja, confondeo_caja:write. Nada garantiza que quien fondea tenga acceso al desglose del país. - El desglose es preventivo y estático; la alerta es reactiva y puntual.
- La implementación ya usa el umbral correcto. El aviso de fondeo compara contra "disponible para distribuir", no contra el saldo total del país (ver 5.4.1) — la duda que dejaba abierta la versión anterior de este documento ya se resolvió con la implementación real.
5.7.6 Con módulo Caja apagado (Ecuador)
✅ Implementado tal como se diseñó. showDistribution (PrefondeoPaisView.vue:32) exige hasCashModule && breakdown !== null: sin cajas, "Distribuido en cajas" sería siempre 0,00 y "Disponible para distribuir" idéntico al saldo del país, así que la pantalla directamente oculta las dos filas derivadas y se ve igual que Prefondeo Holding — solo el saldo, sin la leyenda sobre distribución.
El bloque "Circuito de fondos" de 5.7.4, cuando se implemente, debería seguir la misma regla: con módulo Caja apagado, dos capas (Holding → País), no tres.
5.8 Accesibilidad y prevención de error en todo lo que mueve plata
5.8.1 Confirmaciones: cuándo sí y cuándo no
| Acción | ¿Confirma? | Por qué |
|---|---|---|
| Cerrar caja | ✅ Sí (ConfirmDialog, CajaView.vue:674-683) | Irreversible y cierra el turno |
| Pagar / Cancelar transacción | ✅ Sí (ConfirmActionModal, PagoView.vue:341-360) | Irreversible, plata de un tercero |
| Ajuste negativo | 📋 Propuesto en 5.2.2, no implementado | Afirma un faltante, con consecuencias sobre una persona |
| Fondeo | ❌ No | Suma plata, es rutina |
| Ajuste positivo | ❌ No | Ídem |
| Abrir caja | ❌ No — el monto ya no lo decide el operador | Es un trámite, no una decisión |
| Cargar asiento de Prefondeo País | ❌ No, pese a proponerse en 5.7.3 | Sin implementar |
| Desactivar país / estación | ✅ Sí (ya implementado) | Alcance amplio |
Apagar modulo_caja o encender pago_en_moneda_local | ❌ No — ver 04, Hallazgos | Debería, sobre todo la segunda |
5.8.2 Montos
tabular-nums-financialen toda cifra monetaria.- Siempre 2 decimales.
- Miles con separador
es-GT. - La moneda ahora sí es visible. Era la deuda más concreta de la versión anterior de este documento:
/caja/disponiblesya devuelvecurrency(CashBoxController.php:78) yCajaView.vuela usa en el saldo y en cada movimiento con su propia moneda histórica (:47-57,591). Test:CajaView.spec.ts:171-182("moneda: rotula los montos con la moneda efectiva del país"). - Los saldos en USD (Holding, Prefondeo País) y los de caja en moneda local no se suman ni se comparan visualmente en ninguna pantalla.
5.8.3 Foco, teclado y lectores de pantalla
Sin cambios de fondo respecto de la versión anterior de este documento, salvo que el modal de apertura ahora es más chico (un paso, no dos) y sigue con la misma deuda:
| Punto | Estado |
|---|---|
ConfirmDialog (cierre) | ✅ NModal + role="alertdialog" + aria-labelledby/aria-describedby (frontend/src/components/ConfirmDialog.vue) |
| Modal de apertura | ❌ Sigue siendo un <div> a mano con fixed inset-0 (CajaView.vue:635): sin role="dialog", sin aria-modal, sin focus trap, sin cierre con Escape y sin foco inicial. La reescritura de E4b.1 (que pasó de dos pasos a uno) no aprovechó para migrarlo a NModal |
| Inputs del formulario manual | ❌ Monto y motivo usan placeholder/NSelect sin <label> real (CajaView.vue:463-499) |
| Rango del export | ✅ Tiene <label> (CajaView.vue:522,530) |
| Mensajes de error inline | ❌ Aparecen sin aria-live |
| Estados solo por color | ⚠️ Los badges abierta/cerrada llevan texto además del color. Los montos negativos de la tabla se distinguen por color y por el signo − (CajaView.vue:587-592) |
| Doble submit | ✅ Los botones que postean se deshabilitan mientras corre la request |
📋 Propuesta de UX, sin implementar — migrar el modal de apertura a
NModal, ahora que es más simple que antes de E4b.
5.8.4 Estados de carga
- Todo botón que postea cambia su texto en gerundio (
Abriendo...,Guardando...,Generando...) y queda deshabilitado. - Los saldos nunca muestran un valor viejo mientras se refrescan.
- Tras un movimiento exitoso: refresco de saldo (
refreshSelected,:103-114) + recarga de movimientos, sin volver al selector (:299-301). - Una falla al refrescar el saldo no rompe la pantalla (
:110-113), pero sigue siendo silenciosa — sin indicador de "saldo desactualizado".
5.8.5 Errores: dónde vive cada mensaje
| Tipo de error | Dónde va | Por qué |
|---|---|---|
| Validación de un campo | Inline, pegado al formulario | El foco ya está ahí |
| Rechazo del backend sobre la operación en curso | Inline, en el mismo lugar, conservando lo cargado | La corrección es sobre lo que se está haciendo |
| Falla de una acción sin formulario (cierre) | Notificación flotante de error | No hay formulario donde ponerlo |
| Falla de carga de la pantalla | Reemplaza el contenido, con reintento | No hay nada que operar |
| Aviso que no bloquea | Banner ámbar persistente (5.4) | No es un error y no se puede corregir |
Un mensaje de error nunca borra lo que la persona cargó. submitManual() deja monto y motivo intactos cuando falla (CajaView.vue:302-304).
Hallazgos para preguntas abiertas
Nada impide que un usuario tenga dos cajas abiertas a su nombre al mismo tiempo.— Resuelto el 2026-08-06 (USER_ALREADY_HAS_OPEN_CASH_BOX+ índice únicocash_sessions_one_open_per_user;applyCashBoxDiscountahora ordena explícitamente,TransactionController.php:328). Ver 06, pregunta #4.- El selector de cajas no distingue "abierta por vos" de "abierta por otro". El dato viaja en la respuesta y no se usa (
CajaView.vue:368-387). Menos urgente que antes —ya no se puede abrir una segunda caja propia sin darse cuenta—, pero el caso "es la caja de un compañero" sigue sin distinguirse. Propuesta en 5.1.3. - La atribución cruzada PDV↔caja sigue sin validarse dentro de un mismo país (§2.3 del diseño de múltiples cajas) — el cruce entre países sí se cerró con
CASH_BOX_COUNTRY_MISMATCH. ¿Alcanza con hacer visible la caja activa en/pago, o hace falta al menos un aviso cuando la caja abierta pertenece a una sucursal distinta de la que se le va a imputar a la transacción? - ¿El signo del ajuste lo determina el motivo? La propuesta de 5.3.3 elimina una clase entera de error, pero cambia el orden de los campos en modo Ajuste y sigue sin estar en ningún alcance escrito.
- El modal de apertura sigue sin ser accesible (sin
role, sin focus trap, sinEscape,CajaView.vue:635), pese a que E4b.1 lo reescribió entero. ¿Se aprovecha ahora para migrarlo aNModal, o queda como deuda separada? Blindadoquedó solo como motivo de fondeo. Confirmar con tesorería antes de reclasificar, porque implica tocar un ledger inmutable. Ver 06, pregunta #2.- "Distribuido en cajas" suma saldos que pueden estar en otra moneda que el saldo del país. El Prefondeo País es en USD y los saldos de caja están en la moneda de pago del país (
cash_movements.currency). Conpago_en_moneda_local = false(Guatemala hoy) los dos son dólares y la resta es directa;distributedInCashBoxes()ya filtra explícitamente por la moneda de pago vigente del país (CountryPrefundingService.php:57-61), así que con la bandera encendida algún día, la mezcla de monedas simplemente se excluye de la suma en vez de convertirse — queda como una decisión ya tomada en el código, sin discusión de producto documentada en ningún lado. ¿Es el comportamiento deseado, o debería convertir al TC vigente? - No hay vínculo formal entre el asiento de la Holding y el asiento del país que origina. Ver 5.7.3 y 06, pregunta #11.
- ¿Se pueden cargar asientos negativos en el Prefondeo País (o en la Holding)? Un barrido de fondos del país de vuelta a la Holding, o la corrección de un asiento cargado de más, hoy no tienen forma de representarse: las dos altas validan monto > 0 y los dos ledgers son inmutables.
- La Holding puede quedar negativa y nada lo valida ni lo avisa. Ver 5.5.2 y ADR-010, todavía sin implementar.

