Skip to content

E4 — Módulo Caja: diseño técnico

Complementa doc/plans/plan-soterex-v2-cierre-mvp1-y-caja.md (E4.1-E4.6) con el detalle de implementación. Ver también doc/architecture/ADR-005-modulo-caja-doble-saldo.md para el razonamiento detrás de las decisiones estructurales. Handoff a backend-dev.

⚠️ Parcialmente desactualizado (2026-08-05). E4 se implementó tal cual está acá y se mergeó a main, pero la revisión con el cliente del 2026-08-05 cambió varias de estas secciones. Lo que ya no rige:

SecciónQué cambió
§1.4 cash_sessionsopened_by_supervisor_id pasa a nullable y deja de escribirse
§2.1 Pago con Caja activase elimina el corte por prefondeo; valida solo la caja. El TC deja de ser obligatorio (países que pagan en USD)
§2.2 Apertura de cajasin doble usuario; el saldo inicial se hereda del cierre anterior en vez de tipearse
§2.4 Fondeomotivos pasan a catálogo cerrado; fondear por encima del prefondeo del país avisa, no bloquea

Todo el resto (modelo de ledger, cash_movements, TC, reportes) sigue siendo la referencia válida. Ver ADR-007 y 2026-08-05-e4b-ajustes-caja-reunion-cliente.md.

0. Eliminación de commission_pct (no es una migración de datos, es un borrado)

Confirmado con Carlos (2026-07-31), tras investigar el spec real de la API (doc/site/public/openapi.yaml) y la regla de negocio ya confirmada el 25/07 (doc/functional/2026-07-24-soterex-integracion/02-roles-permisos.md §2.5): la comisión no es un dato que CIS configure. Soterex la calcula y la devuelve granular por transacción en fee (SendRequestResponse.fee, doc/site/public/openapi.yaml:222-224) — no hay "comisión por país" real en ningún lado, y la que existía en countries.commission_pct (3.5% para GT) ya convivía desconectada del mock de Soterex (1.5% hardcodeado en SoterexClient::mockSendRequestResponse()) desde el día 1. Además, la comisión es información restringida desde el 25/07 — nunca se muestra en pantallas operativas, solo en Reportes y Dashboard de Supervisor (TransactionResource ya la borra siempre de las respuestas operativas). Por lo tanto: commission_pct se elimina, no se reemplaza por ningún campo nuevo en countries. El wizard de pago de E5 no muestra comisión en ningún paso (ver plan-soterex-v2-cierre-mvp1-y-caja.md, decisión #10 corregida).

  • Migración: drop_commission_pct_from_countries_table — un solo paso, sin backfill (no hay dato que preservar, no se reemplaza por nada).
  • CountryController: sacar commission_pct de validación (create/update), fillable, y de los arrays before/after de auditoría (backend/app/Http/Controllers/CountryController.php:33,57,64,72).
  • frontend/src/views/admin/PaisesView.vue: sacar el campo/input de comisión del formulario (líneas 16, 40, 49, 56, 72, 144, 193) y el tipo en frontend/src/services/countries.ts:7,14.
  • CountrySeeder: sacar commission_pct de los 3 países seedeados.
  • TransactionSeeder: fee deja de derivarse de commission_pct (amount × pct / 100, TransactionSeeder.php:55,90) — alinear con el mismo criterio que usa el mock real de Soterex: fee = round(amount * 0.015, 2) (mismo 1.5% que SoterexClient::mockSendRequestResponse():189), para que dev y el mock dejen de estar desincronizados. Si en el futuro se quiere variar el % por transacción para que los datos de prueba se vean más reales, hacerlo ahí mismo (en el seeder o en el mock), nunca reintroduciendo un campo editable en countries.
  • Tests a actualizar (sacar todo lo que referencia commission_pct, no reemplazar por un campo equivalente): CountryApiTest, AuditLogApiTest, SeedsPermissionScenarios.
  • doc/functional/: 03-user-flows.md:27 y 05-secuencias-pantalla.md:13 todavía describen que el detalle de transacción muestra "comisión" — quedaron desactualizados desde la corrección del 25/07 (02-roles-permisos.md §2.5 ya lo contradice) y ahora también por esta eliminación. Corregirlos es trabajo de E7 (documentación total), no bloquea E4, pero dejarlo anotado para no perderlo.

1. Modelo de datos

1.1 countries (ALTER)

ColumnaTipoNotas
modulo_cajaboolean default falseFlag on/off (E4.1). Apagado = cero cambios de comportamiento.
local_currencychar(3) nullableISO 4217 (ej. GTQ). null mientras modulo_caja=false.
commission_pctSe elimina (sin reemplazo), ver §0.

Validación a nivel aplicación (no DB): si modulo_caja=true, local_currency es requerido (CountryController@update).

1.2 exchange_rates (tabla nueva)

Historial inmutable — cada edición es un INSERT, nunca UPDATE (decisión #5 del plan).

id                uuid pk
country_id        uuid fk countries, not null
rate              decimal(12,6) not null        -- unidades de moneda local por 1 USD
effective_from    timestamp not null default now()
created_by        uuid fk users, not null
created_at        timestamp not null
  • Índice (country_id, effective_from desc) — es como se resuelve "el TC vigente ahora": el registro más reciente con effective_from <= now() para ese país.
  • Sin updated_at (igual criterio que AuditLog — es un log, no una entidad editable).
  • ExchangeRateService::current(Country $country): ?ExchangeRate — null si nunca se cargó uno (pago con Caja activa sin TC vigente = bloqueado, E4.6).
  • Cada alta genera un evento EXCHANGE_RATE_UPDATED en AuditLog (metadata: country_id, rate, previous_rate).

1.3 cash_boxes (tabla nueva)

Una fila por sucursal con modulo_caja activo — no por sucursal-día (eso es cash_sessions). Es solo el ancla para relacionar sesiones/movimientos a una sucursal; no tiene columna de saldo (el saldo se deriva, ver §2).

id            uuid pk
station_id    uuid fk stations, unique, not null
created_at, updated_at

Se crea automáticamente (o vía seeder/comando) para toda station de un país con modulo_caja=true — confirmar con backend-dev si conviene un observer en Station (crear CashBox al activar el módulo del país) o un comando caja:sincronizar-cajas corrido después de activar el flag. Sugerido: observer, para que activar el flag en Ubicaciones sea un solo paso para el Admin.

1.4 cash_sessions (tabla nueva)

Apertura/cierre diario por sucursal.

id                    uuid pk
cash_box_id           uuid fk cash_boxes, not null
status                varchar   -- 'OPEN' | 'CLOSED'
opening_balance        decimal(12,2) not null   -- moneda local
closing_balance         decimal(12,2) nullable   -- moneda local, se completa al cerrar
opened_by_user_id      uuid fk users, not null   -- Backoffice que abrió
opened_by_supervisor_id uuid fk users, not null  -- Supervisor que confirmó (doble usuario, decisión #13)
closed_by_user_id      uuid fk users, nullable
opening_receipt_path   varchar nullable   -- referencia al PDF (E4.5/E5.3)
closing_receipt_path   varchar nullable
opened_at             timestamp not null
closed_at             timestamp nullable
  • Constraint de aplicación (no DB, Postgres no tiene partial unique fácil de portar entre providers sin cuidado — usar unique index where status='OPEN' si Aurora Postgres lo soporta, confirmar con backend-dev/devops): una sola sesión OPEN por cash_box_id a la vez. Si Postgres parcial no es viable, validar en el service con lockForUpdate igual que TransactionController::pay.
  • "Caja abierta" = existe una cash_session con status='OPEN' para esa cash_box_id — no hay columna de estado en cash_boxes (se deriva, mismo criterio que el saldo).

1.5 cash_movements (tabla nueva)

Ledger inmutable — es la fuente de verdad del saldo de caja (igual patrón que Transaction+ PrefundingEntry para el prefondeo).

id                uuid pk
cash_box_id       uuid fk cash_boxes, not null
cash_session_id   uuid fk cash_sessions, not null
type              varchar   -- 'DEPOSITO_APERTURA' | 'FONDEO' | 'PAGO' | 'AJUSTE' | 'RETIRO_CIERRE'
amount_local      decimal(12,2) not null   -- signo: + entra, - sale
amount_usd        decimal(12,2) nullable   -- solo en 'PAGO' (monto original de la transacción en USD)
exchange_rate     decimal(12,6) nullable   -- TC aplicado, solo en 'PAGO' (congelado, decisión: reimpresión usa este valor, no el vigente)
transaction_id    uuid fk transactions, nullable   -- solo en 'PAGO'
motivo            varchar nullable   -- 'FONDEO' y 'AJUSTE' lo piden
created_by        uuid fk users, not null
created_at        timestamp not null
  • CashBoxService::balance(CashBox $cashBox): floatSUM(amount_local) sobre todos los movimientos de la caja (no filtrado por sesión: si "caja abierta" es la invariante para poder operar, el saldo histórico total sigue siendo la suma de todo, igual criterio que PrefundingService::balance()).
  • Evento de auditoría por cada movimiento (E4.4) — AuditLog::record() con action según el tipo (CASH_FUNDED, CASH_SESSION_OPENED, CASH_SESSION_CLOSED, CASH_ADJUSTMENT), no hace falta un evento separado para PAGO si ya se audita TRANSACTION_STATUS_CHANGE — agregar cash_movement_id a esa metadata existente en vez de duplicar.

2. Transacciones atómicas

Todas usan DB::transaction() + lockForUpdate() sobre las filas que se leen-y-escriben, mismo patrón que TransactionController::pay() (ver backend/app/Http/Controllers/TransactionController.php:174).

2.1 Pago con Caja activa (extiende TransactionController::pay)

Reemplaza/envuelve la lógica actual cuando transaction.country.modulo_caja === true:

DB::transaction(function () {
    lock Transaction (ya existe)
    if status !== ACCEPTED → 409 (ya existe)

    balance_prefondeo = PrefundingService::balance(country)      # ya existe
    required_usd = amount + fee                                   # SIN CAMBIOS — sigue siendo el
                                                                    # fee real de la transacción
                                                                    # (Soterex), igual que hoy
    if required_usd > balance_prefondeo → 422 INSUFFICIENT_PREFUNDING (ya existe)

    if country.modulo_caja:
        cash_box = CashBox::where(station_id: resolved_station_id)  # la del PDV que resuelve
        open_session = lock + fetch cash_session WHERE cash_box_id=cash_box.id AND status=OPEN
        if !open_session → 422 CASH_SESSION_CLOSED

        rate = ExchangeRateService::current(country)
        if !rate → 422 NO_EXCHANGE_RATE

        amount_local = amount × rate.rate           # SOLO el monto de la transacción, la comisión
                                                       # nunca toca la caja (decisión #10)
        balance_caja = CashBoxService::balance(cash_box)
        if amount_local > balance_caja → 422 INSUFFICIENT_CASH_BALANCE

        insert CashMovement(
            cash_box_id, cash_session_id: open_session.id, type: PAGO,
            amount_local: -amount_local, amount_usd: amount, exchange_rate: rate.rate,
            transaction_id: transaction.id, created_by: user.id,
        )

    # resto igual que hoy: status=PAID, station_id, resolved_by_user_id, save, AuditLog
})

Resuelto (2026-07-31, ver §0 y ADR-005): no existe commission_amount — se eliminó commission_pct de countries en vez de migrarlo. El único campo de comisión es transactions.fee, que ya es correcto en este cálculo desde antes de E4 — E4 no toca esta línea, solo agrega el bloque de caja local debajo.

2.2 Apertura de caja

POST /api/caja/{cashBox}/apertura
Body: { opening_balance: number, supervisor_id_token: string }

DB::transaction(function () {
    lock CashBox
    existing_open = CashSession WHERE cash_box_id=X AND status=OPEN
    if existing_open → 409 CASH_ALREADY_OPEN

    supervisor = verificar supervisor_id_token con FirebaseAuth::verifyIdToken() (igual que
                 VerifyFirebaseToken), resolver a User local por firebase_uid, confirmar que tiene
                 rol Supervisor con permiso apertura_cierre_caja en el país de esta estación
                 → si no, 422 SUPERVISOR_INVALID

    session = insert CashSession(
        cash_box_id, status: OPEN, opening_balance, opened_by_user_id: user.id (el del Bearer),
        opened_by_supervisor_id: supervisor.id, opened_at: now(),
    )
    insert CashMovement(type: DEPOSITO_APERTURA, amount_local: +opening_balance, cash_session_id: session.id, created_by: user.id)

    generar comprobante de apertura (E4.5, ver nota) → session.opening_receipt_path
    AuditLog CASH_SESSION_OPENED (metadata: opened_by, supervisor_id, opening_balance)
})

2.3 Cierre de caja (por monto total, decisión #14)

POST /api/caja/{cashBox}/cierre

DB::transaction(function () {
    lock CashBox
    session = CashSession WHERE cash_box_id=X AND status=OPEN, lockForUpdate → si no existe, 409 CASH_NOT_OPEN

    final_balance = CashBoxService::balance(cash_box)   # incluye el DEPOSITO_APERTURA + todo lo demás

    insert CashMovement(type: RETIRO_CIERRE, amount_local: -final_balance, cash_session_id: session.id, created_by: user.id)
    # post-condición: CashBoxService::balance(cash_box) === 0 exacto — testear esto explícitamente (E4.6)

    session.status = CLOSED
    session.closing_balance = final_balance
    session.closed_by_user_id = user.id
    session.closed_at = now()
    session.save()

    generar comprobante de cierre (con el detalle de todos los CashMovement de la sesión) → session.closing_receipt_path
    AuditLog CASH_SESSION_CLOSED (metadata: closing_balance, movements_count)
})

2.4 Fondeo (Supervisor exclusivo, decisión #11)

POST /api/caja/{cashBox}/fondeo
Body: { amount: number, motivo: string }
Gate: perm:fondeo_caja,write (ya sembrado en RoleSeeder — solo Supervisor/Admin lo tienen en true)

DB::transaction(function () {
    session = CashSession WHERE cash_box_id=X AND status=OPEN → si no existe, 422 CASH_SESSION_CLOSED
    insert CashMovement(type: FONDEO, amount_local: +amount, motivo, cash_session_id: session.id, created_by: user.id)
    AuditLog CASH_FUNDED
})

3. Contrato de API (resumen — completar OpenAPI en doc/site/ cuando se implemente)

MétodoRutaPermisoBodyNotas
GET/api/caja/mi-sucursalcaja:readDevuelve cash_box, saldo actual, sesión abierta (o null) de la sucursal activa del usuario. Alimenta E5.1b.
POST/api/caja/{cashBox}/aperturaapertura_cierre_caja:write{opening_balance, supervisor_id_token}§2.2
POST/api/caja/{cashBox}/cierreapertura_cierre_caja:write{}§2.3
POST/api/caja/{cashBox}/fondeofondeo_caja:write{amount, motivo}§2.4, solo Supervisor/Admin por permiso
GET/api/caja/{cashBox}/movimientoscaja:readLista paginada de cash_movements, incluye links a comprobantes de apertura/cierre (E4.5)
GET/api/tipo-cambio/{country}tipo_cambio:readTC vigente + historial
POST/api/tipo-cambio/{country}tipo_cambio:write{rate}Admin/Supervisor únicamente (permiso ya sembrado así en E3)
PATCH/api/paises/{country} (existente)abm_paises:writeagrega modulo_caja, local_currency; saca commission_pctExtiende CountryController@update existente, no un endpoint nuevo

4. Fuera de alcance de E4 (queda para E5)

  • Wizard de pago con los 3 documentos y su storage en S3.
  • Generación real de PDFs (papeleta + comprobantes apertura/cierre) — E4 solo deja los campos opening_receipt_path/closing_receipt_path listos para que E5.3 los complete con el mismo motor. Si conviene, E4 puede dejar un stub/placeholder de comprobante (ej. un JSON o un PDF mínimo sin el diseño térmico final) para no bloquear los tests de E4.6 en el path del comprobante — a criterio de backend-dev.

5. Tests obligatorios (E4.6, expandido con casos de la sección 2)

  • Descuento atómico con rollback si falla una pata (ej. caja sin saldo pero prefondeo sí alcanza → ninguno de los dos se descuenta).
  • Comisión descuenta solo del prefondeo USD, jamás de la caja (test explícito comparando amount_local del CashMovement tipo PAGO contra transaction.amount, no contra amount+fee).
  • Pago rechazado: caja cerrada / saldo insuficiente en caja / saldo insuficiente en prefondeo / sin TC vigente — 4 tests separados, cada uno verifica que NINGÚN movimiento ni cambio de estado quedó aplicado (rollback real, no parcial).
  • Fondeo denegado a roles sin fondeo_caja:write (403).
  • Apertura sin token de supervisor válido → 422, no crea CashSession.
  • Apertura con supervisor_id_token de un usuario sin rol Supervisor → 422 (no alcanza con "es un token válido de Firebase", tiene que resolver a un Supervisor real con el permiso en ese país).
  • Doble apertura sobre la misma caja → 409, no duplica CashSession OPEN.
  • Cierre deja CashBoxService::balance() === 0 exacto (no "aproximadamente cero" — decimal exacto).
  • Cierre sobre caja ya cerrada → 409.
  • Historial de TC intacto tras ediciones (INSERT, nunca UPDATE — test que verifica que el registro viejo sigue existiendo con su rate original después de cargar uno nuevo).
  • modulo_caja=false en el país → el flujo de pago actual (M1) sigue exactamente igual, cero CashMovement creados, cero validaciones de caja aplicadas — test de regresión explícito.

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