Skip to content

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ónPermisoBackofficeSupervisorAdmin
Ver cajas, saldo y movimientoscaja:read
Abrir y cerrar cajaapertura_cierre_caja:write
Fondear y ajustar cajafondeo_caja:write
Cargar tipo de cambiotipo_cambio:write❌ solo lectura
Cargar asientos de Prefondeo Holdingprefondeo:write
Cargar asientos de Prefondeo Paísprefondeo_pais:write
Agregar cajas a un PDVabm_estaciones:write
Pagar una transaccióntransacciones:write
Reporte de Cajasreportes: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_INVALID ni el campo supervisor_id_token: la columna opened_by_supervisor_id sigue en la tabla (las sesiones históricas la tienen) pero quedó nullable y 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 literalmente balance() — 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) y cash_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ún lockForUpdate de 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 el catch de QueryException reconoce 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_INVALID si 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 suma amount_local de 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_balance queda 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_CIERRE por 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 el RETIRO_CIERRE y recrear un DEPOSITO_APERTURA por 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). Elegir Otro exige motivo_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 un SELECT DISTINCT sobre 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 warning con código OVER_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). Blindado queda deliberadamente fuera —un blindado siempre suma, es fondeo, no ajuste— y Otro exige motivo_detalle igual que en fondeo. Si tesorería termina queriendo Blindado tambié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 (NSelect en modo filterable+tag, alimentado por un SELECT DISTINCT motivo sobre 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ó a Otro conservando el texto original en motivo_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 PermissionService que 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 único cash_sessions_one_open_per_user garantizan que nadie tenga dos sesiones OPEN a 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:

CapaQué descuentaMonedaPuede quedar negativaBloquea el pago
Prefondeo Holdingprincipal + comisiónUSD (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íssolo el principalUSDSolo en países sin módulo Caja (Ecuador, con EPOS)
Caja de la sucursalsolo el principalUSD o moneda local, según pago_en_moneda_local del paísNoSí — con módulo Caja activo, es la única validación
  • Con módulo Caja activo, INSUFFICIENT_PREFUNDING ya 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_RATE no 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_rate queda null si 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::transaction que 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ía CashBoxService::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 siempre effective_from = now() (:67), aunque el modelo y ExchangeRateService::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 (default false) 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_RATE no 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 HoldingGET/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ísGET/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_entries no referencia a prefunding_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.

  1. 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ó con CASH_BOX_COUNTRY_MISMATCH (2.5-2.6), pero el caso dentro de un mismo país sigue abierto. Ver 06, pregunta #6.
  2. Nadie valida quién cierra la caja. cierre() no exige que sea el mismo usuario que la abrió: cualquiera con apertura_cierre_caja:write en 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?
  3. 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.
  4. agregarCaja no 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.
  5. CASH_NOT_OPEN sigue diciendo "esta sucursal" cuando el alcance real es la caja (CashBoxController.php:236). CASH_ALREADY_OPEN ya 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.
  6. 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() ni CountryPrefundingController::store() validan una fecha: siempre es el momento de la carga. Contradicción documentación↔código, no resuelta acá.
  7. No se puede programar un tipo de cambio futuro. ExchangeRateController::store() fija siempre effective_from = now() (:67), aunque el modelo y ExchangeRateService::current() soportan vigencias futuras. Cargar el TC del día siguiente por adelantado es imposible desde la UI.
  8. 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).

← Anterior · Índice · Siguiente: Diagramas de Actividad →

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