Skip to content

Diagramas de Actividad

Análisis Funcional: Módulo Caja — parte 4 de 7. ← Anterior · Índice · Siguiente: Secuencias de Pantalla →

Mientras los flujos muestran qué decide el usuario, acá se muestra quién le habla a quién: operador, aplicación, base de datos y Soterex. Es el nivel donde se ven las dos cosas que hacen confiable al módulo —la atomicidad y los locks— y el único lugar donde el circuito completo del dinero se lee de una sola pasada. Describe el sistema tal como corre hoy en main (E4b, mergeado 2026-08-06).

3.1 Pago completo con módulo Caja activo

TransactionController::pay() (backend/app/Http/Controllers/TransactionController.php:182) y applyCashBoxDiscount() (:319).

Lo que hay que leer en este diagrama:

  • Una sola transacción de base de datos envuelve todo. No hay un momento en que la caja esté descontada y la transacción todavía en ACCEPTED, ni al revés. Un 422 tardío —por ejemplo, saldo de caja insuficiente después de haber pasado los chequeos de documentos y país— revierte también lo que se hubiera insertado antes.
  • La bifurcación por modulo_caja es la que decide qué se valida, no un corte previo común. Con el módulo activo, el prefondeo del país no se toca en absoluto durante el pago —la única validación de saldo es la caja—; sin el módulo, es al revés: se valida contra el Prefondeo País y no hay caja de la que descontar. No existe un chequeo de prefondeo que corra siempre y "después" se sume el de caja: son dos caminos excluyentes.
  • Dos locks pesimistas, no uno, cuando hay caja. La fila de la transacción se bloquea para que dos operadores no puedan pagarla a la vez —ver 3.4—, y la fila de la sesión de caja se bloquea para que dos operaciones sobre la misma caja no lean un saldo desactualizado. Como fondeo, ajuste y pago bloquean la misma fila de cash_sessions, todas quedan serializadas entre sí.
  • Ningún saldo se lee de una columna. Tanto el Prefondeo País/Holding como la caja se derivan con un SUM sobre su ledger respectivo. No hay una segunda fuente de verdad que pueda desincronizarse.
  • El prefondeo (cualquiera de sus dos capas) no tiene asiento de egreso. Su saldo baja porque la transacción pasó a PAID, no porque se haya escrito un movimiento. La caja, en cambio, sí registra un movimiento explícito. Son dos mecanismos distintos que conviene no confundir al leer los reportes.
  • La notificación a Soterex todavía no existe: en el código hay un comentario marcando dónde va cuando M5 conecte la API real. El diagrama la ubica fuera del COMMIT, que es donde corresponde que viva cuando se implemente: una llamada a un tercero no puede quedar dentro de una transacción de base de datos abierta.

3.2 El circuito completo del dinero

Tres capas: la Holding en USD (global, descuenta principal + comisión), el Prefondeo País en USD (por país, solo principales, admite negativo) y la Caja (por caja, solo principal, en USD o moneda local según el país). El ejemplo numérico es el que Diego Sánchez planteó en la reunión del 2026-08-05 y Carlos San Martín validó — ver ADR-007.

El mismo movimiento, en tabla:

CapaAntes del pagoDespués del pagoQué descontóPuede quedar negativa
Prefondeo Holding200193principal + comisiónSí, pero nada lo valida ni lo avisa hoy — ver Hallazgos y ADR-010
Prefondeo País10095solo el principal
Caja 12015solo el principal, en USD o moneda local según el paísNo
Caja 22020nada — pagó la caja 1No

Las tres reglas que este diagrama fija y que son fáciles de perder de vista:

  1. Bajar plata de una capa a la siguiente no gasta nada. Ni el asiento del país descuenta la holding, ni fondear una caja descuenta el país. Las capas superiores solo bajan cuando se paga una transacción. Hasta entonces, la misma plata está contada en todos los niveles a la vez, que es exactamente lo que se quiere ver.
  2. La comisión existe en una sola capa. Solo la holding la descuenta. El país y la caja ven principales, que es lo que hace que el saldo del país sea conciliable contra su extracto bancario sin ajustes mentales.
  3. La única capa que puede frenar un pago es la caja (en países con módulo Caja activo). El país puede quedar en rojo —eso es información contable válida, no un error— y la holding no valida nada en el momento del pago, ni siquiera para avisar.

3.3 Apertura y cierre con arrastre del saldo entre sesiones

La apertura hereda el saldo de la última sesión cerrada de esa misma caja; el cierre no genera ningún movimiento y no toca el saldo. Ninguna de las dos operaciones escribe en cash_movements — la continuidad la garantiza directamente opening_balance = balance() calculado al momento de abrir.

Por qué se resolvió así y no reteniendo el mecanismo viejo (RETIRO_CIERRE al cerrar + DEPOSITO_APERTURA al abrir por el mismo monto):

  • Es más simple, no solo distinto. La alternativa evaluada en la reunión conservaba los dos movimientos y hacía descansar la continuidad en que opening_balance == closing_balance de la sesión previa. Se descartó por costo, no por concepto — terminó implementándose sin ningún movimiento de apertura ni de cierre: el saldo del ledger nunca se mueve entre una sesión y la siguiente, así que no hay nada que "arrastrar" explícitamente.
  • La invariante vieja —"la suma de los movimientos de una sesión cerrada es cero"— se abandonó a propósito, no se preservó. El reporte de Cajas y cualquier otro consumidor tienen que calcular el saldo sumando todos los movimientos de la caja (CashBoxService::balance()), nunca "los de la sesión actual".
  • La diferencia de arqueo deja de poder disimularse. Antes, tipear un monto de apertura distinto al cierre anterior absorbía silenciosamente cualquier faltante. Ahora, la única forma de cambiar ese número es un AJUSTE con motivo o un FONDEO, y las dos cosas quedan tipificadas para que tesorería las revise. Textual de la reunión: "que comience con el mismo saldo final del día anterior le da más seguridad al manejo del dinero".
  • Efecto colateral bueno, no buscado a propósito: como el saldo mostrado nunca depende de si hay una sesión abierta o cerrada, el intervalo entre el cierre de un turno y la apertura del siguiente ya no muestra la caja en cero — muestra el saldo real, todo el tiempo. El problema que tenía el mecanismo viejo (ver Hallazgos de la versión anterior de este documento) desapareció como consecuencia del diseño, no porque se haya resuelto aparte.

3.4 Concurrencia

Los tres casos que el módulo maneja explícitamente. Todos son esperables en el uso normal, no situaciones raras: la bolsa de transacciones ACCEPTED es compartida por país, un PDV con varias cajas tiene varias personas operando a la vez, y un mismo usuario puede tener acceso a más de una caja.

Dos operadores pagando la misma transacción

La clave es que el re-chequeo de estado ocurre después de tomar el lock, no con el estado que la pantalla tenía cargada. Sin eso, las dos transacciones leerían ACCEPTED y las dos pagarían.

Dos aperturas simultáneas de la misma caja

Las dos barreras, en orden:

  1. lockForUpdate sobre la caja y sobre su sesión abierta (backend/app/Http/Controllers/CashBoxController.php:130-135): serializa a los dos operadores, el segundo relee y encuentra la sesión ya creada.
  2. Índice único parcial en la base (CREATE UNIQUE INDEX cash_sessions_one_open_per_box ON cash_sessions (cash_box_id) WHERE status = 'OPEN', backend/database/migrations/2026_07_31_152554_create_cash_sessions_table.php:38): si por lo que fuera el lock no alcanzara, el INSERT de la segunda sesión viola la unicidad. La aplicación atrapa esa excepción, la reconoce por el nombre del índice y responde el mismo 409 CASH_ALREADY_OPEN en vez de un 500 (CashBoxController.php:196-208). El operador ve el mismo mensaje por cualquiera de los dos caminos.

El mismo usuario abriendo dos cajas distintas a la vez

Caso agregado el 2026-08-06 (hallazgo de code review): las dos cajas son filas distintas, así que el lockForUpdate de arriba —que serializa por caja— no alcanza a serializar dos aperturas simultáneas de la misma persona sobre cajas diferentes. Necesita su propia barrera:

  • El chequeo explícito en PHP (CashBoxController.php:151-164) cubre el caso normal — sin concurrencia real, alcanza. La condición de carrera de arriba es rara pero posible, y por eso el índice único cash_sessions_one_open_per_user (backend/database/migrations/2026_08_06_130000_e4b_una_sola_caja_abierta_por_usuario.php:30) es la garantía real, con el mismo patrón de reconocimiento por nombre que _per_box (CashBoxController.php:210-215).
  • La migración que creó este índice tuvo que cerrar primero las sesiones duplicadas que ya existían de antes de la regla (dejando abierta la más reciente por usuario) — si no, el índice no se podía crear sobre datos que ya lo violaban.

Vale la pena notar qué no hace falta blindar aparte: fondeo, ajuste y pago sobre una misma caja bloquean todos la misma fila de cash_sessions, así que quedan serializados entre sí sin ninguna protección adicional. Cada uno calcula el saldo después de tomar el lock, de modo que dos ajustes simultáneos no pueden dejar la caja en negativo saltándose la validación.

Hallazgos para preguntas abiertas

Además de los ya listados en los flujos, armando estos diagramas aparecieron estos puntos. No los resolví — quedan para Preguntas Abiertas.

  1. El ejemplo canónico no cierra entre sus dos fuentes. El plan E4b muestra en su tabla el saldo Holding pasando de 195 a 193, mientras que el árbol de arriba de esa misma tabla —y ADR-007— parten de 200. Con 200 y un pago de 5 + 2, el resultado correcto es 193, así que el 195 de la tabla parece un arrastre de otra versión del ejemplo. Acá se documentó la versión de ADR-007 y del árbol: 200 → 193. Conviene confirmarlo antes de que ese número termine en un test.
  2. La notificación a Soterex sigue sin existir, más de un mes después de que el módulo Caja entrara en staging. El Análisis Funcional original muestra el POST Notifications como parte del pago, pero en el código sigue siendo un comentario marcando el punto de integración de M5. Ninguna documentación aclara que ese paso todavía no ocurre.
  3. La detección de la violación de unicidad depende de una coincidencia de texto, ahora en dos índices en vez de uno. El manejo de los 409 de respaldo busca el nombre del índice dentro del mensaje de error (CashBoxController.php:203,210) en vez de mirar el código de estado de Postgres. Si alguna vez se renombra alguno de los dos índices o cambia el formato del mensaje del driver, esa carrera pasa a devolver un 500. Es un detalle de implementación, pero afecta un comportamiento que este documento describe como garantizado.
  4. La Holding puede quedar negativa sin que nada lo detecte. A diferencia del Prefondeo País (§3.2, tabla) y de la Caja, la Holding no tiene ningún chequeo de saldo ni aviso — ver ADR-010, todavía sin implementar.

← Anterior · Índice · Siguiente: Secuencias de Pantalla →

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