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éndoc/architecture/ADR-005-modulo-caja-doble-saldo.mdpara el razonamiento detrás de las decisiones estructurales. Handoff abackend-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ón Qué cambió §1.4 cash_sessionsopened_by_supervisor_idpasa anullabley deja de escribirse§2.1 Pago con Caja activa se 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 caja sin doble usuario; el saldo inicial se hereda del cierre anterior en vez de tipearse §2.4 Fondeo motivos 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 y2026-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: sacarcommission_pctde validación (create/update), fillable, y de los arraysbefore/afterde 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 enfrontend/src/services/countries.ts:7,14.CountrySeeder: sacarcommission_pctde los 3 países seedeados.TransactionSeeder:feedeja de derivarse decommission_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% queSoterexClient::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 encountries.- 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:27y05-secuencias-pantalla.md:13todaví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)
| Columna | Tipo | Notas |
|---|---|---|
modulo_caja | boolean default false | Flag on/off (E4.1). Apagado = cero cambios de comportamiento. |
local_currency | char(3) nullable | ISO 4217 (ej. GTQ). null mientras modulo_caja=false. |
commission_pct | — | Se 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 coneffective_from <= now()para ese país. - Sin
updated_at(igual criterio queAuditLog— 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_UPDATEDenAuditLog(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_atSe 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 conbackend-dev/devops): una sola sesiónOPENporcash_box_ida la vez. Si Postgres parcial no es viable, validar en el service conlockForUpdateigual queTransactionController::pay. - "Caja abierta" = existe una
cash_sessionconstatus='OPEN'para esacash_box_id— no hay columna de estado encash_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 nullCashBoxService::balance(CashBox $cashBox): float—SUM(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 quePrefundingService::balance()).- Evento de auditoría por cada movimiento (E4.4) —
AuditLog::record()conactionsegún el tipo (CASH_FUNDED,CASH_SESSION_OPENED,CASH_SESSION_CLOSED,CASH_ADJUSTMENT), no hace falta un evento separado paraPAGOsi ya se auditaTRANSACTION_STATUS_CHANGE— agregarcash_movement_ida 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étodo | Ruta | Permiso | Body | Notas |
|---|---|---|---|---|
GET | /api/caja/mi-sucursal | caja:read | — | Devuelve cash_box, saldo actual, sesión abierta (o null) de la sucursal activa del usuario. Alimenta E5.1b. |
POST | /api/caja/{cashBox}/apertura | apertura_cierre_caja:write | {opening_balance, supervisor_id_token} | §2.2 |
POST | /api/caja/{cashBox}/cierre | apertura_cierre_caja:write | {} | §2.3 |
POST | /api/caja/{cashBox}/fondeo | fondeo_caja:write | {amount, motivo} | §2.4, solo Supervisor/Admin por permiso |
GET | /api/caja/{cashBox}/movimientos | caja:read | — | Lista paginada de cash_movements, incluye links a comprobantes de apertura/cierre (E4.5) |
GET | /api/tipo-cambio/{country} | tipo_cambio:read | — | TC 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:write | agrega modulo_caja, local_currency; saca commission_pct | Extiende 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_pathlistos 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 debackend-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_localdelCashMovementtipoPAGOcontratransaction.amount, no contraamount+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_tokende 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
CashSessionOPEN. - Cierre deja
CashBoxService::balance() === 0exacto (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
rateoriginal después de cargar uno nuevo). modulo_caja=falseen el país → el flujo de pago actual (M1) sigue exactamente igual, ceroCashMovementcreados, cero validaciones de caja aplicadas — test de regresión explícito.

