Skip to content

ADR-005: Módulo Caja opcional por país + modelo de doble saldo

Date: 2026-07-31 Status: Accepted — superseded in part by ADR-007 (2026-08-05)

⚠️ Qué de este ADR ya no rige. Tras la revisión con el cliente del 2026-08-05, ADR-007 reemplaza dos decisiones de acá:

  1. El modelo de doble saldo pasa a ser de tres capas — apareció la Holding como capa societaria por el contrato nuevo. El pool en USD que este ADR llama "prefondeo por país" es en realidad el de la Holding (descuenta amount + fee); se agrega un Prefondeo País que descuenta solo principales y admite saldo negativo. La caja no cambia.
  2. La apertura de caja con doble usuario se elimina — sin reautenticación in-band del Supervisor. Además el saldo inicial deja de tipearse: se hereda del cierre anterior.

Lo que sigue vigente: ledgers inmutables, saldos siempre derivados (nunca cacheados), módulo Caja opcional por país, la comisión fuera de la caja, y la eliminación de commission_pct.

Context

El plan doc/plans/plan-soterex-v2-cierre-mvp1-y-caja.md (E4) introduce un caso de uso nuevo: países sin EPOS (ej. Guatemala) necesitan que la app lleve, además del prefondeo por país en USD que ya existe (PrefundingEntry, PrefundingService), un saldo físico de caja por sucursal en moneda local, con apertura/cierre diario y fondeo manual. Ecuador sigue con su EPOS y no activa esto — la app debe seguir comportándose exactamente igual que hoy cuando el módulo está apagado.

Dos preguntas de diseño estructurales:

  1. ¿Un saldo nuevo reemplaza al prefondeo, o conviven dos saldos independientes que se descuentan juntos en cada pago? El plan es explícito (decisión #3/#4): conviven, y ambos se descuentan en el mismo pago — el prefondeo sigue siendo la fuente de verdad para Soterex (USD, por país), la caja es un control operativo local (moneda local, por sucursal) que no existía antes.
  2. ¿Cómo se representa "comisión"? Al diseñar esto se encontró una contradicción entre el plan (que asume "valor fijo por unidad, editable por país, como está hoy") y el código real (countries.commission_pct es un porcentaje editable en ABM Países, fee = amount × commission_pct / 100 solo en el seeder de desarrollo). Investigación más profunda (agentes de exploración, 2026-07-31) encontró algo más grave: el modelo entero de "comisión editable por país" no refleja la realidad del sistema. El spec real de la API de Soterex (doc/site/public/openapi.yaml:222-224) muestra que fee es un campo que Soterex calcula y devuelve por transacción en la respuesta de SendRequest — CIS Latam no lo fija, no lo edita, y no puede fijarlo (no viaja en el request). Peor: el mock actual de desarrollo (SoterexClient::mockSendRequestResponse()) ya simula ese fee con 1.5% hardcodeado, completamente desconectado del commission_pct de ABM Países (3.5% para Guatemala) — dos números que conviven sin relación desde el día 1. Además, doc/functional/2026-07-24-soterex-integracion/02-roles-permisos.md §2.5 (confirmado con el cliente el 2026-07-25, tres días antes de este plan v2) ya establece que la comisión es información restringida: nunca se muestra en pantallas operativas (listado, detalle, modales de pago/cancelación), solo en Reportes y en el Dashboard de Supervisor — regla que el backend ya aplica (TransactionResource borra fee incondicionalmente de toda respuesta operativa). Confirmado con Carlos (2026-07-31): el campo editable de ABM Países se elimina — no es una migración de porcentaje a monto fijo, es la eliminación de un concepto que nunca debió ser editable por CIS.

Decision

Dos ledgers independientes, mismo patrón que PrefundingEntry/PrefundingService ya validado en el código. No se agrega una tabla de "saldo actual" en ningún lado — el saldo siempre se deriva sumando movimientos (event-sourcing liviano, ya es el patrón de este proyecto, no uno nuevo). Un pago con Caja activa escribe en ambos ledgers dentro de la misma transacción de DB.

countries.commission_pct se elimina, no se migra a otro campo. No hay ningún valor de comisión a nivel país que tenga sentido almacenar o editar — la única fuente de verdad es transactions.fee, que llega granular por transacción desde Soterex (hoy simulado en SoterexClient::mockSendRequestResponse(), real cuando M5 conecte de verdad). ABM Países pierde el campo de comisión por completo. El wizard de pago de E5 no muestra comisión en ningún paso — ya es información restringida por regla de negocio confirmada (ver Context), y el wizard lo opera Backoffice, el rol exactamente restringido. El descuento del prefondeo (amount + fee) no cambia: ya es correcto tal como está implementado hoy en TransactionController::pay() — E4 no toca ese cálculo, solo le agrega el descuento paralelo de la caja local cuando el país tiene el módulo activo.

Apertura de caja con doble usuario: reautenticación in-band, no un segundo dispositivo. El usuario Backoffice inicia la apertura; el modal de confirmación le pide al Supervisor loguearse ahí mismo (signInWithEmailAndPassword del SDK cliente de Firebase, sin pisar la sesión del Backoffice) y el ID token resultante del Supervisor viaja en el payload de apertura junto al del Backoffice (que ya viaja siempre en Authorization: Bearer). El backend verifica el segundo token con el mismo FirebaseAuth::verifyIdToken() que ya usa VerifyFirebaseToken — sin dependencia nueva, sin almacenar contraseñas. Mismo mecanismo para el cierre no hace falta (el plan no lo pide — cierre lo opera Backoffice solo, ver decisión #14 y la matriz de permisos ya sembrada en E3, apertura_cierre_caja).

cash_movements es inmutable y es la fuente de verdad del saldo (igual criterio que PrefundingEntry+Transaction para el prefondeo): correcciones = movimiento inverso nuevo, nunca UPDATE/DELETE sobre un movimiento existente.

Alternatives Considered

  • Fusionar prefondeo y caja en un solo concepto de "saldo por sucursal". Descartado: el prefondeo es por país (fuente para Soterex, en USD) y preexiste; forzarlo a nivel sucursal rompe M1/M2 ya en producción y contradice la decisión #3 del plan, que es explícita en mantenerlos separados.
  • Tabla cash_boxes con columna current_balance actualizada con cada movimiento (en vez de derivarlo). Más rápido de leer, pero introduce una segunda fuente de verdad que puede desincronizarse con cash_movements bajo concurrencia o bugs — mismo motivo por el que PrefundingService::balance() ya deriva en vez de cachear. Se puede agregar un materialized balance después si el SUM() se vuelve un problema de performance real (no hay evidencia de eso a esta escala — una sucursal factura decenas de movimientos por día, no miles).
  • Apertura con doble usuario vía PIN corto en vez de reautenticación Firebase completa. Un PIN de 4-6 dígitos es más rápido de tipear, pero es una credencial nueva para administrar (alta, reset, storage) fuera del sistema de auth existente, y el plan no lo pide — usa "reautenticación" como patrón ya conocido (Google, bancos) en vez de inventar un mecanismo de credenciales paralelo.

Consequences

  • Positive: el patrón de ledger inmutable ya está validado en producción (prefondeo) — mismo approach, menos riesgo. Eliminar commission_pct es una simplificación, no una migración de datos: se dropea la columna, no hace falta backfill ni intervención manual de Admin.
  • Negative: el seeder de desarrollo (TransactionSeeder) pierde su fuente actual de fee (amount × commission_pct / 100) y necesita un reemplazo que no dependa de un campo eliminado — ver doc/plans/2026-07-31-e4-modulo-caja-diseno-tecnico.md §0 para el criterio (alinear con el mismo % hardcodeado que ya usa SoterexClient::mockSendRequestResponse(), para que dev y el mock de Soterex dejen de estar desincronizados). Reportes/Dashboard, que ya leen transactions.fee directamente, no cambian.
  • Neutral: el saldo de caja se calcula con un SUM() sobre cash_movements en cada lectura, igual que el prefondeo — aceptable a esta escala, revisar si el volumen de movimientos por sucursal crece órdenes de magnitud.

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