Flujos de Caja
Análisis Funcional: Módulo Caja — parte 3 de 7. ← Anterior · Índice · Siguiente: Diagramas de Actividad →
Un flujo por operación, describiendo el sistema tal como corre hoy en main — E4b (ADR-007) mergeó el 2026-08-06, más de un mes después de la reunión con el cliente del 2026-08-05 que lo definió. Donde algo cambió respecto de lo que E4/E4-multi habían implementado originalmente, queda una nota de Historia — no para llevar la cuenta de versiones, sino porque el porqué de una decisión a veces solo se entiende sabiendo qué reemplazó.
Quién puede hacer qué
Permisos reales del seed (backend/database/seeders/RoleSeeder.php:36-114). Todos se evalúan por país: el gate grueso está en la ruta (backend/routes/api.php:100-197, un grupo Route::middleware('perm:<módulo>,<acción>') por fila de esta tabla) y el fino en CashBoxController::authorizeStationScope() (backend/app/Http/Controllers/CashBoxController.php:621), que compara contra el país de la sucursal dueña de la caja y, desde el 2026-08-06, contra los PDVs a los que el usuario tiene alcance (ver 06, pregunta #5).
| Acción | Permiso | Backoffice | Supervisor | Admin |
|---|---|---|---|---|
| Ver cajas, saldo y movimientos | caja:read | ✅ | ✅ | ✅ |
| Abrir y cerrar caja | apertura_cierre_caja:write | ✅ | ✅ | ✅ |
| Fondear y ajustar caja | fondeo_caja:write | ❌ | ✅ | ✅ |
| Cargar tipo de cambio | tipo_cambio:write | ❌ solo lectura | ✅ | ✅ |
| Cargar asientos de Prefondeo Holding | prefondeo:write | ❌ | ✅ | ✅ |
| Cargar asientos de Prefondeo País | prefondeo_pais:write | ❌ | ✅ | ✅ |
| Agregar cajas a un PDV | abm_estaciones:write | ❌ | ❌ | ✅ |
| Pagar una transacción | transacciones:write | ✅ | ✅ | ✅ |
| Reporte de Cajas | reportes:read | ❌ | ✅ | ✅ |
2.1 Apertura de caja
POST /caja/{cashBox}/apertura (CashBoxController.php:124). Requiere apertura_cierre_caja:write en el país de la sucursal. Un solo usuario, sin reautenticación de ningún tipo: la abre el operador con su propia sesión.
Reglas y validaciones:
- Un solo usuario, sin autorización de Supervisor. No existe el código
SUPERVISOR_INVALIDni el camposupervisor_id_token: la columnaopened_by_supervisor_idsigue en la tabla (las sesiones históricas la tienen) pero quedónullabley ya no se escribe (backend/database/migrations/2026_08_06_100000_e4b1_caja_sin_supervisor_y_motivos_cerrados.php:24). - El saldo inicial se hereda, no se tipea.
CashBoxService::inheritedOpeningBalance()(backend/app/Services/CashBoxService.php:79) es literalmentebalance()— el saldo que el ledger de esta caja ya tiene — y se muestra en el modal como dato de solo lectura, no editable ni siquiera como sugerencia. Si la plata física no coincide, se corrige con un AJUSTE (2.4); si entra plata nueva, con un FONDEO (2.3) — nunca editando el saldo de apertura. - Sin movimiento de apertura. No se crea ningún
DEPOSITO_APERTURA(CashBoxController.php:176-180): la plata nunca salió de la caja porque el cierre ya no la retira (ver 2.2), así que crear un depósito acá la duplicaría. - Una sola sesión abierta por caja Y por usuario. Dos índices únicos parciales en la base:
cash_sessions_one_open_per_box(backend/database/migrations/2026_07_31_152554_create_cash_sessions_table.php:38, cubre dos aperturas de la misma caja) ycash_sessions_one_open_per_user(backend/database/migrations/2026_08_06_130000_e4b_una_sola_caja_abierta_por_usuario.php:30, cubre dos aperturas del mismo usuario sobre cajas distintas — un caso que ningúnlockForUpdatede este método alcanza a serializar, porque son filas diferentes). El controller chequea las dos cosas explícitamente antes de insertar (CashBoxController.php:132-164) y elcatchdeQueryExceptionreconoce ambos índices por nombre como defensa en profundidad (:196-218). Ver 3.4. - Todo ocurre dentro de una transacción de base de datos: si algo falla, no queda ni sesión ni cambio de estado.
Historia. Hasta el 2026-08-05 esto pedía un monto tipeado por el operador y la confirmación in-band de un Supervisor —el control vivía en esa firma— con el código
SUPERVISOR_INVALIDsi el segundo usuario no era Supervisor o no tenía el permiso (ADR-005). La revisión con el cliente lo simplificó: el ledger inmutable ya daba la trazabilidad que se buscaba con la firma, y exigir a un Supervisor físicamente en el PDV sumaba fricción diaria sin agregar nada. Diego (tesorería), sobre el saldo: "que lo tome automáticamente, no debería ser como sugerencia". Ver ADR-007 §4 y §5.
2.2 Cierre de caja
POST /caja/{cashBox}/cierre (CashBoxController.php:221), mismo permiso que la apertura. Sin confirmación de Supervisor: el cierre siempre fue de un solo usuario.
Reglas y validaciones:
- Cierre por monto total, no por conteo. El sistema no pide arquear ni tipear nada: toma el saldo que él mismo lleva (
CashBoxService::balance(),backend/app/Services/CashBoxService.php:31, que sumaamount_localde todos los movimientos de la caja en su moneda de pago vigente, no solo los de la sesión). - El cierre ya no genera ningún movimiento. No existe más el
RETIRO_CIERRE: antes barría la caja a cero porque la plata se retiraba de verdad al cerrar; ahora que el saldo se hereda, ese ida y vuelta contable no representaba ningún movimiento real y dejaba la caja mostrando cero entre el cierre y la apertura siguiente, con la plata físicamente en el PDV (CashBoxController.php:240-249). - La caja no queda en cero. Sigue mostrando exactamente el mismo saldo que tenía, que es lo que la apertura siguiente hereda. La invariante vieja —"la suma de los movimientos de una sesión cerrada es cero"— ya no existe: cualquier tabla o texto que la dé por válida está desactualizado.
- El
closing_balancequeda guardado en la sesión: es lo que se audita y lo que hereda la apertura siguiente (CashBoxController.php:253). - El código no exige que quien cierra sea quien abrió (ver Hallazgos).
Historia. Hasta el 2026-08-05 el cierre creaba un
RETIRO_CIERREpor el saldo total en negativo y dejaba la caja en cero exacto; la apertura siguiente volvía a pedir un monto tipeado sin ninguna relación con ese cierre. La revisión con el cliente decidió arrastrar el saldo en vez de barrerlo: "que comience con el mismo saldo final del día anterior le da más seguridad al manejo del dinero" (Teresa). La alternativa evaluada —conservar elRETIRO_CIERREy recrear unDEPOSITO_APERTURApor el mismo monto al abrir— se descartó por costo, no por concepto: terminó implementándose sin ningún movimiento de por medio, más simple que las dos opciones consideradas en su momento (ADR-007, Alternatives).
2.3 Fondeo de caja
POST /caja/{cashBox}/fondeo (CashBoxController.php:273). Permiso fondeo_caja:write: en el seed lo tienen solo Supervisor y Admin; Backoffice lo tiene en false en los tres flags, así que ni ve el bloque de movimiento manual (frontend/src/views/CajaView.vue:439).
Reglas y validaciones:
- El fondeo solo suma (
min:0.01). Sacar plata del cajón no es un fondeo negativo: es un ajuste (2.4). - Exige sesión abierta. Una caja cerrada no acepta movimientos de ningún tipo.
- El motivo sale de un catálogo cerrado, no de texto libre:
Fondeo inicial,Blindado,Transferencia financiero,Otro(CashMovement::MOTIVOS_FONDEO,backend/app/Models/CashMovement.php:32-37). ElegirOtroexigemotivo_detalle(CashBoxController::validateMontoYMotivo(),:472-495); un motivo fuera del catálogo se rechaza con 422.GET /caja/motivos?type=FONDEO(CashBoxController::motivos(),:508-513) expone ese catálogo fijo al frontend — ya no hace unSELECT DISTINCTsobre lo cargado antes. - Fondear no descuenta ningún otro saldo. Mover plata del país al cajón es distribución, no gasto: ni el Prefondeo País ni el Holding se tocan (
CountryPrefundingService::distributedInCashBoxes(),backend/app/Services/CountryPrefundingService.php:50-71, es justamente la suma que hace que ese número no baje). - Aviso posterior, no bloqueante. Si el fondeo deja el disponible para distribuir del país (saldo del país menos lo ya repartido en sus cajas) en negativo, el backend igual responde 201 y agrega un
warningcon códigoOVER_COUNTRY_PREFUNDING(CashBoxController::overDistributionWarning(),:406-425). No es una confirmación previa: el movimiento ya se creó cuando se calcula el aviso. Diego (tesorería) lo pidió textual: "como una alerta más que un validador".
2.4 Ajuste de caja
POST /caja/{cashBox}/ajuste (CashBoxController.php:336). Comparte el permiso del fondeo (fondeo_caja:write) a propósito: un ajuste puede restar plata, que es más sensible que fondear, así que quedó con el mismo nivel de restricción, no con uno más permisivo.
Reglas y validaciones:
- El ajuste suma o resta (
not_in:0), a diferencia del fondeo. - Nunca puede dejar la caja en negativo (
CashBoxController.php:360-366): el error devuelve el saldo actual para que el operador vea contra qué está ajustando. - Exige sesión abierta.
- El motivo también sale de un catálogo cerrado:
Faltante de arqueo,Sobrante de arqueo,Otro(CashMovement::MOTIVOS_AJUSTE,backend/app/Models/CashMovement.php:46-50).Blindadoqueda deliberadamente fuera —un blindado siempre suma, es fondeo, no ajuste— yOtroexigemotivo_detalleigual que en fondeo. Si tesorería termina queriendoBlindadotambién acá, es agregar una constante (ver 06, pregunta #2). - Nunca se edita ni se borra un movimiento previo. Corregir es agregar un movimiento nuevo: el ledger es inmutable por diseño (ADR-005).
Historia. Hasta el 2026-08-05 el motivo de fondeo y ajuste era texto libre con autocompletado (
NSelecten modofilterable+tag, alimentado por unSELECT DISTINCT motivosobre lo ya cargado). El cliente lo cerró en la reunión porque la misma operación entraba como "blindado" o "transferencia de dinero" según quién la tipeara, y después no se podía agrupar nada. Los motivos libres ya cargados se migraron: los que coincidían con una entrada del catálogo se normalizaron, el resto pasó aOtroconservando el texto original enmotivo_detalle(backend/database/migrations/2026_08_06_100000_e4b1_caja_sin_supervisor_y_motivos_cerrados.php).
2.5 Varias cajas en una sucursal: cuál se opera y de cuál sale la plata
Una sucursal puede tener varias cajas físicas numeradas 1, 2, 3… (cash_boxes.number, único junto con station_id). Son dos decisiones distintas y desacopladas: qué caja se mira en pantalla, y qué caja paga.
- La elección no se persiste entre sesiones: cada vez que se entra a Caja con más de una disponible, se vuelve a elegir. Con una sola caja no se agrega fricción — se entra directo.
- El botón "Cambiar de caja" solo aparece si hay más de una (
frontend/src/views/CajaView.vue:395). - El listado respeta el alcance del usuario por país y por PDV, con el mismo
PermissionServiceque el resto de la app (CashBoxController::disponibles(),:49-88).
De qué caja sale la plata al pagar — con varias cajas por sucursal ya no se puede derivar del PDV, así que se deriva de quién está pagando:
- Una caja abierta por persona a la vez ya no es un supuesto: es una regla forzada. Desde el 2026-08-06,
USER_ALREADY_HAS_OPEN_CASH_BOX(2.1) y el índice únicocash_sessions_one_open_per_usergarantizan que nadie tenga dos sesionesOPENa su nombre — ver 06, pregunta #4. Cambio de turno sigue siendo cerrar y volver a abrir. - Que otro operador tenga abierta otra caja de la misma sucursal es irrelevante: cada pago descuenta la caja de quien paga, nunca la del otro.
- La caja tiene que ser del mismo país que la transacción (
CASH_BOX_COUNTRY_MISMATCH,TransactionController::applyCashBoxDiscount(),:342-355) — sin esto, un usuario con alcance en dos países con módulo Caja podía pagar una transacción de un país descontando la caja del otro, al TC del otro. Pero dentro de un mismo país, el cruce PDV↔caja sigue sin validarse: el PDV que se atribuye a la transacción se resuelve por separado (TransactionController::resolveStationForResolution(),:471-473) y no se compara contra la sucursal de la caja que paga. Fue una omisión deliberada del diseño de E4-multi — ver Hallazgos.
2.6 Pago de una transacción
PATCH /transacciones/{transaction}/pay (TransactionController.php:182), permiso transacciones:write. Con modulo_caja apagado en el país, el bloque de caja no corre y el flujo es el de M1 más la validación de Prefondeo País.
Qué descuenta cada capa:
| Capa | Qué descuenta | Moneda | Puede quedar negativa | Bloquea el pago |
|---|---|---|---|---|
| Prefondeo Holding | principal + comisión | USD (global, todos los países) | Sí — nada lo valida ni lo avisa hoy (ADR-007 §1, nota ⑴; el aviso hacia esta capa lo propone ADR-010, Status: Proposed, sin implementar) | No |
| Prefondeo País | solo el principal | USD | Sí | Solo en países sin módulo Caja (Ecuador, con EPOS) |
| Caja de la sucursal | solo el principal | USD o moneda local, según pago_en_moneda_local del país | No | Sí — con módulo Caja activo, es la única validación |
- Con módulo Caja activo,
INSUFFICIENT_PREFUNDINGya no puede ocurrir. La única validación de saldo es la caja del operador — el saldo del país puede estar en cero por una transferencia en trámite mientras el PDV tiene plata, y perder la transacción por eso sería un problema administrativo, no de fondos (TransactionController.php:218-231). En países sin módulo Caja —Ecuador, que opera con su EPOS— se mantiene la validación, ahora contra el Prefondeo País (:226-244): decisión consciente y confirmada por Carlos, no una omisión. (06, pregunta #8). - La caja tiene que ser del mismo país que la transacción,
CASH_BOX_COUNTRY_MISMATCH(TransactionController.php:342-355) — ver 2.5. - Si el país paga en dólares (
pago_en_moneda_local = false, default), no se exige TC vigente:NO_EXCHANGE_RATEno puede dispararse y el monto descontado es el nominal, sin conversión (:357-371). Guatemala está en este caso hoy. - La comisión nunca toca la caja. Es una decisión de negocio, no un detalle: el cajón físico paga lo que recibe el beneficiario, nada más (
TransactionController.php:310-313). - El TC se congela en el movimiento solo si hubo conversión (
exchange_ratequedanullsi el país paga en dólares): una reimpresión posterior usa ese valor, no el vigente al momento de reimprimir (:393-395). - Atomicidad real: el descuento de caja ocurre dentro del mismo
DB::transactionque el cambio de estado. Si la caja no alcanza, no queda ni movimiento ni transacción pagada. Ver 3.1. - La comisión nunca se devuelve en las respuestas operativas, y el error de prefondeo omite a propósito el monto requerido: junto al monto ya visible, restando se obtendría la comisión.
2.7 Alta de cajas en una sucursal
Vive en el ABM de Estaciones, con permiso abm_estaciones:write, o sea solo Admin (backend/routes/api.php:193-197; StationController::agregarCaja(), :117).
- Las cajas no se nombran a mano: son "Caja 1", "Caja 2"… dentro de su sucursal. La numeración es secuencial y no reutiliza números (
CashBoxService::addCashBoxForStation(),backend/app/Services/CashBoxService.php:110). - No hay baja ni desactivación de cajas en este alcance: solo se ven cuántas hay y se agregan.
- Activar el módulo en el país es un solo paso para el Admin: la sincronización de cajas ocurre sola (
CountryController::update(),backend/app/Http/Controllers/CountryController.php:94-99, víaCashBoxService::ensureCashBoxesForCountry(),backend/app/Services/CashBoxService.php:89-95).
2.8 Tipo de cambio: carga, vigencia y el caso de pago en dólares
GET/POST /tipo-cambio/{country} (backend/app/Http/Controllers/ExchangeRateController.php), permiso tipo_cambio: Admin y Supervisor escriben, Backoffice solo lee.
- El historial es inmutable: cada edición es un alta. El registro viejo sigue existiendo con su tasa original, que es lo que hace auditable una reimpresión.
- Vigente = el más reciente con
effective_from <= now()(backend/app/Services/ExchangeRateService.php:15). - La tasa se expresa como unidades de moneda local por 1 USD, con 6 decimales.
- El selector de países de esta pantalla lista solo los países con módulo Caja activo, y se sirve por un endpoint propio para no obligar a tener permisos de ABM de Países (
ExchangeRateController::paises(),:34-41). - No se puede programar un tipo de cambio futuro desde la UI:
store()fija siempreeffective_from = now()(:67), aunque el modelo yExchangeRateService::current()soportan vigencias futuras — ver Hallazgos. - Guatemala no tiene autorización para cambio de divisa, así que paga en dólares:
countries.pago_en_moneda_local(defaultfalse) lo determina por país (backend/database/migrations/2026_08_06_110000_e4b2_moneda_de_pago_por_pais.php). Con la bandera apagada,NO_EXCHANGE_RATEno puede dispararse — la pantalla de Tipo de Cambio sigue existiendo tal cual, lista para cuando Guatemala obtenga la autorización o aparezca el esquema de casa de cambio tercerizada que mencionó el cliente como segunda instancia. En pantalla, la columna "TC aplicado" se oculta cuando ningún movimiento del rango tiene tasa cargada (CashBoxController::reporte(),:554-567).
2.9 Prefondeo: Holding, País y sus saldos
Desde el 2026-08-06 el prefondeo son dos pantallas y dos capas, no una — apareció la Holding por el contrato nuevo con Soterex. Ver el detalle contable completo en 01, Modelo de saldos y el ejemplo numérico en 3.2.
Prefondeo Holding — GET/POST /prefondeo (PrefundingController), permiso prefondeo: es la tabla prefunding_entries que ya existía en E4, renombrada en concepto y en UI, no en base de datos. Descuenta principal + comisión y el saldo que se muestra es la suma global de todos los países (PrefundingService::globalBalance(), backend/app/Services/PrefundingService.php:40-49) — el ledger conserva country_id en cada asiento y cada pago para el desglose por país que se le informa a Soterex, pero el número que ve la pantalla es el total.
Prefondeo País — GET/POST /prefondeo-pais (backend/app/Http/Controllers/CountryPrefundingController.php), permiso prefondeo_pais: Supervisor y Admin escriben, Backoffice no accede. Tabla nueva country_prefunding_entries, con carga manual por tesorería.
- Descuenta solo principales, nunca la comisión — se liquida a fin de mes entre Holding y Soterex, el país no la ve (
CountryPrefundingService::balance(),backend/app/Services/CountryPrefundingService.php:28-38). - Admite saldo negativo. Si la caja paga y el país estaba en cero, queda en rojo reflejando la deuda real con la Holding — es información contable válida, no un error.
- El saldo no alcanza por sí solo. Mover plata del país a una caja no descuenta este saldo (es distribución, no gasto), así que la pantalla muestra tres números:
CountryPrefundingService::breakdown()(:84-94) — saldo del país, distribuido en cajas (suma de los saldos de todas sus cajas, filtrando por la moneda de pago vigente,distributedInCashBoxes(),:50-71) y disponible para distribuir (la resta de los dos anteriores, que también puede ser negativo si las cajas ya tienen más plata de la que el país tiene contablemente). Ver la implementación de pantalla en 4.4. - Fondear una caja no toca ni el Prefondeo País ni la Holding. Es distribución banco→cajón, no gasto.
- No hay vínculo formal entre un asiento de la Holding y el de País que lo originó —
country_prefunding_entriesno referencia aprefunding_entries. Son dos cargas manuales independientes. Ver 06, pregunta #11.
Hallazgos para preguntas abiertas
Cosas que el código hace y ninguna documentación explica, o donde código y planes se contradicen. No las resolví — quedan para el documento de Preguntas Abiertas.
- La caja que paga no se valida contra el PDV de la transacción, solo contra su país.
TransactionController::resolveStationForResolution()(:471-473) resuelve el PDV que se atribuye a la transacción por un camino separado de la caja que paga, y no se comparan. El diseño de E4-multi documenta y acepta el desacople PDV↔caja dentro de una sucursal; el cruce multipaís ya se cerró conCASH_BOX_COUNTRY_MISMATCH(2.5-2.6), pero el caso dentro de un mismo país sigue abierto. Ver 06, pregunta #6. - Nadie valida quién cierra la caja.
cierre()no exige que sea el mismo usuario que la abrió: cualquiera conapertura_cierre_caja:writeen ese país puede cerrar la caja de otro operador — y al hacerlo, lo deja sin poder pagar hasta que vuelva a abrir. ¿Es deseado? - Fondeo y ajuste siguen exigiendo sesión abierta. Si llega un blindado fuera de turno o se detecta un faltante después del cierre, hoy no hay forma de registrarlo sin volver a abrir la caja — y con el saldo arrastrándose entre sesiones (2.1-2.2), ese desfasaje pesa más que antes. Ver 06, pregunta #11.
agregarCajano verifica que el país tenga el módulo Caja activo (StationController.php:117), a diferencia del alta de sucursal (:56), que sí lo chequea. Se pueden crear cajas en países sin módulo Caja; no rompen nada porque nadie las mira, pero aparecen en la base.CASH_NOT_OPENsigue diciendo "esta sucursal" cuando el alcance real es la caja (CashBoxController.php:236).CASH_ALREADY_OPENya se corrigió a "esta caja" (:140,:206) al escribir este análisis — la inconsistencia entre los dos mensajes es nueva: antes los dos decían "sucursal" por igual, ahora uno quedó corregido y el otro no.- El asiento de Prefondeo País (y el de Holding) no aceptan fecha. El Análisis Funcional original §3.4 dice que el Supervisor completa "país, monto, fecha", pero ni
PrefundingController::store()niCountryPrefundingController::store()validan una fecha: siempre es el momento de la carga. Contradicción documentación↔código, no resuelta acá. - No se puede programar un tipo de cambio futuro.
ExchangeRateController::store()fija siempreeffective_from = now()(:67), aunque el modelo yExchangeRateService::current()soportan vigencias futuras. Cargar el TC del día siguiente por adelantado es imposible desde la UI. - La Holding puede quedar negativa y nada lo valida ni lo avisa. A diferencia del Prefondeo País (que sí admite y muestra el negativo) y de la Caja (que nunca puede ir a negativo), la Holding no tiene ningún chequeo — es la asimetría que documenta ADR-010 (Status: Proposed, sin implementar): hay aviso hacia abajo (país→caja,
OVER_COUNTRY_PREFUNDING) pero nada hacia arriba (Holding).

