ADR-007: Prefondeo en tres capas + caja sin autorización de Supervisor
Date: 2026-08-05 Status: Accepted Supersedes in part: ADR-005
Numeración: este ADR es el 007 y no el 006 porque el branch
feature/migracion-azureya reservóADR-006(migración de infraestructura AWS→Azure) sin mergear amaintodavía.
Context
ADR-005 fijó el modelo de doble saldo (prefondeo por país en USD + caja por sucursal en moneda local) y la apertura de caja con doble usuario (reautenticación in-band del Supervisor). E4 y E4-multi se implementaron sobre ese modelo y están mergeados a main.
En la revisión con el cliente del 2026-08-05 (Teresa Ortiz y Diego Sánchez —tesorería— de CIS-EC, Carlos San Martín de CIS-AR), Carlos hizo el walkthrough del módulo ya funcionando y aparecieron tres cosas que ADR-005 no podía haber previsto:
- Apareció una capa societaria nueva: la Holding. Teresa explicó que, por el contrato nuevo, Soterex ya no le manda la plata al país sino a una holding, que se queda con la comisión y baja al país únicamente el principal a pagar. El modelo de dos capas no tiene dónde representar eso: hoy el único pool en USD descuenta
amount + fee, que es exactamente el comportamiento de la holding, y no existe el pool del país, que descuenta soloamount. - Tesorería no quiere que el saldo del país bloquee la operación. Diego fue explícito: el prefondeo del país puede estar en cero porque la transferencia está en trámite, mientras la caja física del PDV tiene plata. En ese escenario la transacción se pierde por un problema administrativo que no tiene nada que ver con la plata que hay en el cajón. "Que me consulte en la caja operativa y listo".
- La autorización del Supervisor para abrir y cerrar caja no aporta control real. Diego la descartó al ver que cada movimiento queda auditado, es inmutable y arrastra el saldo de cierre anterior: el control ya está en el ledger, no en la firma. Sumaba fricción (el Supervisor tiene que estar físicamente en el PDV) sin agregar trazabilidad.
Además, y en la misma reunión, Teresa confirmó que Guatemala no tiene autorización para hacer cambio de divisa: en primera instancia las transacciones se pagan en dólares, no en quetzales. El TC ya implementado no se tira, pero deja de ser un requisito universal del pago.
Decision
1. Tres ledgers, no dos
| Capa | Alcance | Qué descuenta cada pago | Puede quedar negativa |
|---|---|---|---|
| Prefondeo Holding | Global (prefunding_entries, la tabla que ya existe) | amount + fee | Sí, pero nadie la mira ⑴ |
| Prefondeo País (nuevo) | Por país (country_prefunding_entries) | amount (solo principal) | Sí, a propósito |
| Caja operativa | Por caja de PDV (cash_movements) | amount (en la moneda de pago del país) | No — el pago la valida |
⑴ Corregido el 2026-08-06 tras el code review. La versión original de esta tabla decía que la Holding no podía quedar negativa, pero nada lo impide: al sacar la validación de prefondeo del pago (punto 2), ninguna capa chequea el saldo de la Holding. Puede irse a negativo en silencio.
Se deja así a propósito y no se agrega una validación: la Holding es una capa contable, no operativa — nadie opera contra ella en el momento del pago, y bloquear un pago por su saldo es exactamente la fricción que tesorería pidió eliminar. Que quede negativa significa que Soterex todavía no acreditó lo que ya se pagó, y eso se resuelve conciliando, no frenando la caja. Lo que sí falta es que ese saldo sea visible cuando pasa; hoy se muestra sin ningún énfasis.
Se mantiene el patrón de ADR-005: ningún saldo se almacena, los tres se derivan sumando su ledger. La capa nueva no introduce un mecanismo nuevo, replica el que ya está validado.
La comisión nunca baja del país ni de la caja. Se liquida a fin de mes entre la Holding y Soterex; el país solo ve principales. Esto es consistente con lo que ADR-005 ya había decidido para la caja (decisión #10 del plan v2) — acá simplemente se extiende al pool del país.
Mover plata del país a una caja no descuenta el prefondeo del país. Es distribución banco→cajón, no gasto: la plata sigue siendo del país hasta que se paga. El prefondeo del país solo baja cuando se paga una transacción. Esto ya es el comportamiento del código actual; queda escrito acá porque es una invariante contable, no un detalle de implementación.
Alcance del saldo Holding: global, con desglose por país. El ledger conserva country_id en cada asiento y cada pago, pero el saldo que se muestra es la suma global de todos los países.
En la reunión la transcripción admitía las dos lecturas —Carlos habló de un fondeo único para todos los países, Diego ejemplificó con 200 USD repartidos entre Ecuador y Guatemala— y quedó como el único punto que bloqueaba la implementación. Resuelto por Carlos el 2026-08-06: global. El desglose por país que alimenta el reporte hacia Soterex se conserva igual, y si más adelante se define que el saldo es por país, es un cambio de WHERE, no una migración.
El ejemplo canónico (Diego, validado por Carlos en la reunión)
Holding 200 USD
└── prefondeo país Guatemala: 100 (holding sigue en 200 — todavía no se pagó nada)
├── caja 1: 20 (país sigue en 100 — distribución, no gasto)
└── caja 2: 20Pago de 5 USD de principal + 2 USD de comisión desde la caja 1 → caja 1: 15, país: 95, holding: 193.
2. El pago valida solo la caja operativa
Con módulo Caja activo, la única condición de saldo para pagar es que la caja del operador cubra el monto. Se elimina el corte por prefondeo (INSUFFICIENT_PREFUNDING).
Para países sin módulo Caja (Ecuador, que opera con su EPOS y está en producción) se mantiene una validación de saldo, ahora contra el prefondeo del país. Sacarla también ahí dejaría a Ecuador sin ninguna barrera de saldo, y eso no es lo que se discutió: toda la conversación fue sobre Guatemala con caja física. Es una decisión conservadora y explícita, no una omisión.
El prefondeo del país admite saldo negativo. Es la consecuencia directa de lo anterior: si la caja paga y el país estaba en cero, el país queda en rojo y refleja la deuda real con la holding. Un saldo negativo es información contable válida, no un estado de error — se muestra, no se bloquea.
3. Fondear una caja por encima del país avisa, no bloquea
Cuando el monto de fondeo de una caja excede el saldo del prefondeo del país, la operación se completa y se devuelve una advertencia. Diego lo pidió con esas palabras: "como una alerta más que un validador", para cubrir sobregiros legítimos sin frenar la operación.
4. Apertura y cierre de caja: un solo usuario
Se elimina la reautenticación in-band del Supervisor que definió ADR-005. La apertura y el cierre los ejecuta el operador con su propia sesión. El control queda donde ya estaba: cash_movements es inmutable, cada movimiento registra autor y timestamp, y el saldo se arrastra entre sesiones (punto 5), así que un descuadre es visible sin necesidad de una firma en el momento.
cash_sessions.opened_by_supervisor_id pasa a nullable y deja de escribirse. No se borra la columna: las sesiones ya abiertas en staging tienen el dato y el ledger es inmutable por diseño.
5. El saldo inicial de apertura es heredado, no tipeado
La apertura toma como saldo inicial el saldo de cierre de la sesión anterior de esa misma caja (cero si es la primera). No es editable ni se ofrece como sugerencia — Diego y Teresa lo pidieron automático, explícitamente en contra de la sugerencia editable que propuso Carlos.
Las dos formas de cambiar ese número quedan tipificadas y auditadas por separado:
- Falta o sobra plata física →
AJUSTEcon motivo (Faltante de arqueo/Sobrante de arqueo), que tesorería revisa. - Entra plata nueva (blindado, transferencia) →
FONDEO.
Nunca editando el saldo inicial. Teresa: "el que comience con el mismo saldo final del día anterior le da más seguridad al manejo del dinero".
Implementación de la continuidad — corregido el 2026-08-06 al implementar E4b.1. La versión original de este ADR decía conservar el RETIRO_CIERRE que dejaba la caja en cero al cerrar, y que la apertura siguiente recreara un DEPOSITO_APERTURA por el mismo monto, para preservar la invariante "los movimientos de una sesión cerrada suman cero". No sobrevivió al contacto con la implementación, por dos razones:
- Mostraba plata que no estaba y escondía plata que sí. Entre el cierre y la apertura siguiente la caja quedaba en cero, con el efectivo físicamente en el PDV. Eso era coherente cuando el cierre implicaba retirar la plata de verdad; con el saldo heredado, dejó de serlo.
- El saldo heredado quedaba ambiguo. Derivarlo del
closing_balancede la última sesión exige ordenar las sesiones porclosed_at, y esas columnas sontimestampde precisión 0 en Postgres: dos cierres en el mismo segundo hacen el orden indefinido, y se podía heredar el saldo equivocado. Es un bug de plata, no un detalle de estilo.
Lo que rige: el cierre registra closing_balance y no genera ningún movimiento. La plata se queda en la caja, así que el saldo heredado es el saldo del ledger — no hay nada que ordenar ni ninguna resta que pueda estar mal. La apertura tampoco crea un depósito: la plata nunca salió.
La invariante que se pierde (sesión cerrada suma cero) no la usaba nadie: RETIRO_CIERRE solo aparecía como etiqueta en los reportes. Y DEPOSITO_APERTURA queda obsoleto para sesiones nuevas — la plata nueva entra por FONDEO, que es exactamente para lo que Diego pidió el motivo Fondeo inicial.
5b. Una sola caja abierta por usuario
(Agregado el 2026-08-06.) Si un usuario ya tiene una caja abierta a su nombre, no puede abrir otra hasta cerrarla. Cambio de turno = cerrar y volver a abrir.
El diseño de múltiples cajas (E4-multi) ya asumía esto por escrito, pero nada lo hacía cumplir: el índice único cash_sessions_one_open_per_box garantiza una sola sesión abierta por caja, no por usuario. Con dos cajas abiertas a su nombre, el descuento del pago resolvía la caja con un first() sin orden explícito — de cuál salía la plata lo decidía el motor de base de datos.
Se descartó pedirle al operador que elija caja en cada pago: agrega un paso a la operación más frecuente y más sensible del sistema, para resolver un caso que la regla de arriba directamente elimina.
6. La moneda de pago es configurable por país
Nueva bandera countries.pago_en_moneda_local (default: paga en USD). Con la bandera apagada, el pago descuenta el monto nominal en dólares, exchange_rate queda en null y no se exige TC vigente — hoy la falta de TC corta el pago con NO_EXCHANGE_RATE, que bajo esta configuración sería un bloqueo falso.
El módulo de tipo de cambio (tabla, historial inmutable, ABM, auditoría) no se toca: queda listo para cuando Guatemala obtenga la autorización de cambio, o para el esquema de casa de cambio tercerizada que Teresa mencionó como segunda instancia (un tercero hace la conversión y CIS cobra comisión).
El ledger registra en qué moneda se cargó cada movimiento (agregado el 2026-08-06). cash_movements.amount_local guardaba un número sin moneda, que se infería del país. Como la bandera es editable, un país que pasara de moneda local a dólares —o al revés— dejaba los movimientos viejos y los nuevos en el mismo ledger en monedas distintas, y el saldo derivado sumaba peras con manzanas. Con la moneda en cada fila, el histórico nunca se reinterpreta y el cambio se puede hacer cuando el negocio lo necesite.
Se descartaron las alternativas de bloquear el cambio mientras haya movimientos, o exigir todas las cajas cerradas en cero: más baratas de implementar, pero dejaban la trampa puesta para el día que Guatemala consiga la autorización — que es precisamente el escenario que esta bandera existe para soportar.
Alternatives Considered
- Meter la Holding como un país más ("país holding") en vez de una capa nueva. Evitaba la tabla nueva, pero rompe todo lo que hoy asume que un país es una jurisdicción operativa real: el ABM de Países, el scoping de permisos por país, los reportes por país y el dashboard tendrían que aprender a excluir una fila mágica. El costo se paga en cada consulta, para siempre.
- Que el prefondeo del país descuente también la comisión, y netear al cierre de mes. Es lo que hace hoy el pool único. Descartado por pedido directo de tesorería: el país no negocia comisiones ni las paga, verlas en su saldo lo vuelve incomparable contra el extracto bancario del país, que es contra lo que Diego concilia.
- Mantener la validación de prefondeo como bloqueo y resolver los sobregiros con un permiso de excepción. Descartado en la reunión: agrega un rol que tiene que estar disponible en el momento del pago, que es exactamente la fricción que se buscaba sacar. Además la plata del cajón ya está ahí — el sistema estaría negando un pago que físicamente se puede hacer.
Borrar elEs lo que terminó rigiendo (2026-08-06, ver punto 5): la alternativa que se había descartado resultó ser la correcta. El costo estimado no era real —ningún reporte dependía de esa invariante,RETIRO_CIERREy dejar que el saldo persista entre sesiones (punto 5). Más directo de leer, pero reescribe la semántica del cierre y obliga a revisarCajaReportesControllery todo reporte que hoy asume que una sesión cerrada suma cero.RETIRO_CIERREsolo se usaba como etiqueta— y la opción "conservadora" tenía un bug de plata escondido en el orden de las sesiones.- Catálogo de motivos como tabla ABM administrable. Descartado por ahora: Diego pidió una lista cerrada justamente para que nadie escriba "blindado" y "transferencia de dinero" como conceptos distintos. Un ABM reintroduce esa divergencia. Si tesorería necesita motivos nuevos, hoy es agregar una constante; si eso se vuelve frecuente, ahí sí conviene la tabla.
Consequences
- Positive: el saldo del país pasa a ser conciliable contra el extracto bancario del país sin ajustes mentales por comisiones — que es el trabajo concreto de Diego. Sacar el Supervisor de la apertura elimina una dependencia física (que el Supervisor esté en el PDV) que iba a doler todos los días. La bandera de moneda desbloquea a Guatemala sin descartar el módulo de TC ya construido.
- Negative: hay que migrar los motivos de texto libre ya cargados en staging al catálogo cerrado (los que coinciden se mapean, el resto pasa a
Otroconservando el texto original en un campo de detalle — no se pierde dato). Y la app pasa a tener tres saldos que un usuario nuevo tiene que entender antes de operar: es deuda de documentación, que E7 tiene que pagar con un diagrama del circuito completo, no con prosa. - Negative: al sacar la validación de prefondeo del pago, la app deja de tener un freno automático ante un país sin fondear. El control se muda a un lugar más débil (la alerta de fondeo del punto 3, que se puede ignorar) y a la visibilidad del saldo negativo. Es una decisión consciente de tesorería, tomada sabiendo que el riesgo real está acotado por la plata física que hay en el cajón.
- Neutral: ADR-005 queda vigente en todo lo demás — ledgers inmutables, saldos derivados, módulo Caja opcional por país, y la eliminación de
commission_pct. Este ADR no reabre nada de eso.

