# Cola offline de la PWA: que suba, y que ningún cobro se pierda

- **Fecha:** 2026-09-24
- **Rama:** `fix/pwa-cola-offline`
- **Estado:** implementado (PR #267).
- **Origen:** auditoría de la PWA del 2026-09-24 (informe local en `C:\Users\jredondo\credify\informes\2026-09-24-auditoria-pwa\`). Cubre PWA-001 completo, PWA-010 (conversión del modo simplificado), la parte de PWA-008 que es la misma cola y el punto 3 de PWA-011 (la cola de otro usuario se borraba).

## Problema

Las tres colas offline (pagos, visitas y gastos) nunca han subido nada en producción. Consulta de solo lectura del 2026-09-24: 0 pagos con `offline_created_at`, 0 visitas con `device_id`, 0 gastos con `idempotency_key`, en toda la historia.

**Causa raíz.** `sync.js` consulta `where('permanent_error').equals(true)`. IndexedDB no admite booleanos como clave: Dexie lo traduce a `IDBKeyRange.bound(true, true)` y la promesa rechaza **siempre**, aunque la cola esté vacía. Se reprodujo con Dexie 4.4.4 en Chromium (`DataError: … not a valid key`) y en WebKit (`DataError: Provided data is inadequate`). En pagos, la consulta va después de `syncing = true` y fuera del `try`, así que `syncing` queda en `true` hasta recargar y bloquea también a visitas y gastos (`already_syncing`). En visitas y gastos la misma consulta está dentro de un `try` que traga el error: fallan en silencio. Entró con el commit `b9cfd88` (2026-04-14).

**Lo que falla alrededor, y aparecería en cuanto se arregle la consulta:**

1. `SyncPaymentsRequest` valida cada ítem en el FormRequest: un solo ítem malo (o 51 ítems) devuelve 422 al lote completo, sin marcar al culpable, y se repite para siempre.
2. Solo el evento `online` lanza la cola. No se sincroniza al entrar, al volver a primer plano ni periódicamente.
3. Los botones (Inicio, indicador, Perfil, Pagos de hoy) llaman solo a `syncPendingPayments` aunque el contador sume visitas y gastos, y Perfil dice "Sincronización completada" pase lo que pase.
4. `syncErrors` vive en memoria: se pierde al recargar y cada tipo de sync vacía la lista de los otros. `/pwa/sync-errors` no tiene enlace, no lista los gastos con error permanente y se queda en blanco por un locale inválido (`es_CO` en `Intl.NumberFormat`).
5. "Descartar" borra de un toque un cobro que no existe en ningún otro sitio.
6. "Reintentar" un pago va por `POST /pwa/payments`, que rechaza `offline_created_at` de más de 7 días y aplica reglas distintas al lote.
7. **PWA-010:** gastos y promesas de "No paga" se encolan con el valor tal como se escribe y el flag `is_simplified_amount`, pero el lote no envía el flag y el servidor guarda el valor crudo. La empresa 1 tiene el modo simplificado activo (×1000): un gasto de $50.000 quedaría en $50. Los pagos hoy se convierten antes de encolar, pero del 2026-02-05 (`685ea51`) al 2026-03-09 (`10919d0`) `PaymentView` encolaba el valor crudo con el flag: esas filas pueden seguir en algún teléfono y se aplicarían mil veces menores (ver 1.2).
8. **PWA-011 (3):** al entrar otro usuario, `db.clearAll()` vacía las colas del anterior sin avisar.
9. Ningún fallo de la cola llega a Sentry: todo termina en `console`.
10. Un cobro que llega tarde (un atasco de meses, o un teléfono que pasó días sin señal) puede duplicar uno que el cobrador ya cargó a mano con otra clave.

## Principio

**Un cobro capturado en campo nunca se pierde ni se rechaza en silencio.** El servidor lo aplica o lo retiene para que una persona decida. El teléfono solo lo suelta cuando el servidor acusa recibo: `applied`, `duplicate` o `held`.

Esto reemplaza la decisión documentada en `PaymentPolicyValidator` ("el lote no valida porque rechazar lo ya cobrado sería incorrecto"): se mantiene no rechazar, y lo que no se puede aplicar con seguridad se retiene en vez de aplicarse a ciegas.

## Diseño

### 1. Servidor

#### 1.1 Tabla `held_payments` (cobros retenidos)

Tabla propia, fuera de `payments`, para que ningún total, reporte ni KPI que suma pagos la vea hasta que se apruebe.

| Columna | Tipo | Nota |
|---|---|---|
| `id` | bigint | |
| `company_id` | fk | `MultiTenantScope` |
| `idempotency_key` | uuid | único por `(company_id, idempotency_key)` |
| `credit_id` | fk nullable | el crédito al que apuntaba el cobro |
| `captured_by_user_id` | fk | quien cobró (del ítem) |
| `synced_by_user_id` | fk | quien tenía la sesión al subirlo |
| `amount` | decimal(12,2) nullable | nulo solo si llegó ilegible (`invalid`) |
| `payment_method` | string nullable | |
| `payment_date` | date nullable | fecha que eligió el cobrador; nula solo si llegó ilegible |
| `offline_created_at` | datetime nullable | hora del teléfono |
| `device_id`, `latitude`, `longitude` | | |
| `reason` | string | ver 1.2 |
| `reason_detail` | string nullable | texto para el revisor |
| `payload` | json | el ítem tal como llegó |
| `status` | string | `pending` · `approved` · `rejected` |
| `resolved_by_user_id`, `resolved_at`, `resolution_notes` | | |
| `payment_id` | fk nullable | el pago creado al aprobar |
| timestamps | | |

#### 1.2 Lote de pagos (`POST /pwa/sync/payments`)

- `SyncPaymentsRequest` valida solo la forma del lote: `payments` requerido, array, máximo 50. La validación por ítem pasa a `PaymentSyncService`, que responde por ítem.
- Cada ítem acepta un campo nuevo, `captured_by_user_id` (opcional), y `hold: true` (opcional, lo usa "Enviar a revisión"). El motor manda también `is_simplified_amount` (solo lo traen las filas viejas descritas en el problema 7): si es verdadero, `amount` se convierte con el multiplicador de la empresa antes de validar, igual que en visitas y gastos (`App\Support\SimplifiedAmount`). Un retenido guarda el monto ya convertido; lo tecleado queda en `reason_detail` ("capturado en modo simplificado: 50") y en `payload`.
- Orden de decisión por ítem:
  1. **Clave inválida** (falta o no es UUID) → `error`. Es el único caso de datos en que el teléfono lo conserva con error; `error` también cubre un fallo interno al procesar el ítem (se reintenta).
  2. **Ya existe** un pago con esa clave → `duplicate`. Ya existe un retenido con esa clave → `held` (idempotente).
  3. **Se retiene** (`held`, sin tocar saldos) si:
     - `hold: true` → `manual`
     - `captured_by_user_id` distinto del usuario autenticado → `foreign_user` (el usuario capturador debe ser de la misma empresa; si no, `invalid`)
     - datos inválidos (monto no numérico o ≤ 0, fecha ilegible, método desconocido) → `invalid`
     - fecha de pago futura → `future_date`
     - `offline_created_at` o `payment_date` con más de 7 días → `stale`
     - crédito inexistente, no activo, no hoja o no asignado al cobrador → `credit_unavailable`
     - monto mayor que el saldo → `exceeds_balance`
     - ya hay un pago vigente del mismo crédito, mismo monto y misma fecha, con otra clave → `possible_duplicate` (el caso del cobro recargado a mano tras un timeout; sin ventana de días, porque en los créditos diarios el mismo monto se paga todos los días)
     - el ítem pasó **más de 24 h en la cola** (`created_at_local`) y hay un pago vigente del mismo crédito y mismo monto, con `payment_date` a ±7 días y otra clave, cargado **después** de capturar el ítem → `possible_duplicate` (el backlog del día del despliegue: el cobrador vio el saldo igual y lo volvió a cargar a mano otro día). "Cargado" es la hora de captura del otro pago (`offline_created_at`) y, si no vino de una cola, su `created_at`: así las cuotas diarias de un mismo backlog, que llegan juntas, no se retienen unas a otras. Lo que sube en menos de 24 h sigue solo con la regla del mismo día.
  4. En otro caso se **aplica** como hoy (`success`) y el cierre automático respeta `auto_close_on_full_payment`, igual que `PaymentController`.
- Cada resultado lleva además `index`, su posición en el lote recibido: el teléfono lo usa para emparejar un ítem cuya clave vino mal.
- Respuesta por ítem: `{ idempotency_key, status: success|duplicate|held|error, payment_id?, held_id?, reason?, credit_new_status?, credit_is_paid?, error? }`. Resumen: `{ total, success, duplicates, held, errors }`. Se conservan los nombres de estado actuales y se agrega `held`; visitas (`success`) y gastos (`ok`) también conservan los suyos, y el motor del teléfono los traduce por cola.
- El 403 para supervisores (`canRegisterPayments`) sigue igual: es de todo el lote.

#### 1.3 Lotes de visitas y gastos

- Validación por ítem dentro del bucle (un ítem malo ya no tumba el lote).
- **PWA-010:** el ítem trae `is_simplified_amount`. Si es verdadero, `promised_amount` (visitas) y `amount` (gastos) se convierten con el multiplicador de la empresa aunque el modo esté apagado al momento de subir, porque el flag dice cómo se capturó. Los ítems atascados hoy ya guardan el flag, así que se corrigen solos.
- `captured_by_user_id`: si es de la misma empresa, la visita y el gasto se atribuyen a ese usuario. El acceso al crédito de la visita se comprueba contra el capturador, no contra quien sube. Un gasto de otro usuario queda siempre pendiente de aprobación, aunque quien sube sea admin. Si el capturador no es de la empresa, el ítem vuelve como `error`.
- Visitas y gastos no se retienen: el gasto ya pasa por el flujo de aprobación y la visita no mueve dinero.

#### 1.4 "Cobros en revisión" en Filament

- `HeldPaymentResource`, solo lectura más dos acciones, visible para `admin` y `super_admin`. `HeldPaymentPolicy` declara todas las habilidades, porque una policy con solo `before` no restringe nada: `viewAny`, `view`, `approve` y `reject` para admin de la misma empresa y super_admin; `create`, `update`, `delete`, `deleteAny`, `restore` y `forceDelete` siempre `false`.
- Badge en el menú con los pendientes de la empresa.
- Tabla: cobrador, cliente / crédito, monto, fecha de cobro, "capturado hace", motivo, estado.
- **Posibles duplicados** en la vista del retenido: pagos no anulados del mismo crédito, mismo monto, con `payment_date` a ±7 días. La confirmación de Aprobar lo repite ("Atención: hay N pago(s) parecido(s)…") si hay alguno o si se retuvo como `possible_duplicate`.
- **Aprobar:** registra el pago con `PaymentManager::registerPayment`, a nombre del capturador (`registeredByUserId` = `captured_by_user_id`, que es quien tiene el efectivo), con la fecha de cobro original, la misma clave de idempotencia y los metadatos (dispositivo, hora offline, GPS). La acción solo se ofrece si el crédito es activo y hoja, el monto no supera el saldo y el retenido no es `invalid`; si no, la pantalla explica por qué y queda Rechazar. Guarda `payment_id`, `resolved_by_user_id` y `resolved_at`.
- **Rechazar:** motivo obligatorio en `resolution_notes`. No toca saldos.
- Aprobar y rechazar corren en transacción, solo sobre `pending`, con bloqueo de fila, para que dos admins no resuelvan el mismo retenido dos veces.

### 2. Teléfono

#### 2.1 Un solo motor para las tres colas

- `stores/sync.js` reemplaza las tres copias (`syncPendingPayments`, `syncPendingVisits`, `syncPendingExpenses`) por un motor con una configuración por cola: tabla, endpoint, tamaño de lote (50 pagos, 100 visitas y gastos), cómo armar el ítem y cómo leer el resultado.
- Un único candado para `syncAll`, tomado antes del primer `await` y liberado en `finally`. Una llamada durante una ronda no devuelve `already_syncing` ni se une a la que corre (esa ya leyó las colas y no vería lo encolado, reintentado o enviado a revisión después): encadena **una** ronda más, compartida por todas las llamadas que lleguen entretanto, y cada una recibe su resultado.
- Orden: pagos, visitas, gastos.
- **Fallo de transporte** (sin red, timeout, 5xx, 429): el lote vuelve intacto a la cola, sin sumar reintentos, y la ronda termina.
- **Error por ítem** (`error`): suma `retry_count` y guarda `last_error`. A los 3 pasa a `permanent_error` y sale del envío automático.
- `applied`, `duplicate` y `held` borran el ítem de la cola. Si hubo alguno, se refresca el snapshot una sola vez al final (`syncData`).
- `queuePayment`, `queueVisit` y `queueExpense` sellan `captured_by_user_id` con el usuario de la sesión y, con señal, piden una ronda del motor. (PWA-002 las reemplazó por `enviarOEncolar`, que guarda la fila antes del envío directo; ver `2026-09-28-senal-debil-design.md`.)
- El `retryPayment` que iba por `POST /pwa/payments` desaparece: reintentar es devolver el ítem al motor (`retry_count = 0`, `permanent_error = false`).

#### 2.2 Dexie v8

- Quita `permanent_error` de los índices de las tres colas (la causa raíz; el campo sigue en los registros). Nadie vuelve a consultarlo por índice: se usa `filter()`.
- `upgrade`: a los ítems sin `captured_by_user_id` les pone el dueño de los datos del dispositivo (`localStorage.pwa_data_owner_id`), si existe. Si no existe, el servidor los atribuye a quien los suba, como hasta hoy.

#### 2.3 Cuándo sincroniza

`syncAll` corre:
- tras el login, en segundo plano (sin bloquear la entrada) y **antes** de descargar la cartera, así el snapshot ya incluye lo que se subió;
- al volver la conexión (`online`, ya existe);
- al volver la app a primer plano (`visibilitychange` → `visible`), con sesión;
- cada 3 minutos mientras haya ítems reintentables (sin `permanent_error`), la app esté visible y haya sesión.

El login reemplaza `syncInitialData` por `syncAll()` seguido de `syncData()`, que ya es la misma lógica de snapshot. Los disparadores automáticos releen la cola de IndexedDB antes de decidir si hay algo que enviar: otra pestaña o una sesión anterior pudo dejar ítems que el proceso actual no tiene en memoria.

#### 2.4 Cambio de usuario en el mismo teléfono

`discardDataFromAnotherUser` deja de llamar a `db.clearAll()` y llama a `db.clearCaches()`: se borra la caché de lectura, no las colas. Las colas del usuario anterior se suben con su `captured_by_user_id`: sus pagos quedan retenidos (`foreign_user`), y sus visitas y gastos se le atribuyen a él. La UI solo cuenta y muestra los pendientes del usuario de la sesión (la pantalla de errores también), más una línea aparte: "N registros de otro usuario se están enviando a revisión".

`db.clearAll()` se elimina: era su único uso, y "Eliminar datos locales" de Perfil ya solo borra la caché de lectura. Así no queda en la app ningún camino que borre una cola sin que el servidor la haya recibido. El aviso de cierre de sesión cambia en consecuencia: si entra otra persona, lo pendiente "se envía a revisión a tu nombre", no "se descarta".

La sincronización solo corre con token en memoria (`isFullyAuthenticated`): tras una recarga sin red el usuario persiste pero el token no, y una petición sin token daría 401 y un redirect al login en medio del trabajo.

#### 2.5 Lo que ve el cobrador

- Resultado real de cada ronda en un solo aviso: "3 enviados · 1 en revisión · 1 con error", o "No se pudo conectar con el servidor". Perfil deja de anunciar éxito sin haberlo.
- El contador dice "N pendientes por sincronizar" (pagos + visitas + gastos) y todos los botones llaman a `syncAll`.
- **Pantalla de errores** (`/pwa/sync-errors`):
  - lee de IndexedDB los ítems con `last_error` o `permanent_error`, al montar y tras cada ronda, así que sobrevive a una recarga y los tipos no se pisan;
  - muestra solo los del usuario de la sesión (PWA-011): lo de otro usuario del teléfono no se lista, aunque sigue subiendo a su nombre;
  - incluye los gastos;
  - enlazada desde el indicador superior, el aviso de Inicio y las filas "Error" de Pagos de hoy;
  - en pagos, "Descartar" pasa a **"Enviar a revisión"** (manda el ítem con `hold: true`); en visitas y gastos, "Descartar" sigue, con una confirmación en la página que muestra qué se borra;
  - "Reintentar" devuelve el ítem al motor y muestra el resultado real (hoy la visita anuncia éxito sin comprobarlo).
- Locale: `formatAmount` normaliza `es_CO` → `es-CO` y el servidor lo entrega ya normalizado.

#### 2.6 Sentry

- `Sentry.setUser({ id })` al iniciar sesión y `setUser(null)` al cerrarla: sin nombre, correo ni cédula.
- `captureException` en los fallos del motor, con tags: `cola`, `tamano_lote`, `http_status`, `retry_count`.
- `transport: makeBrowserOfflineTransport(makeFetchTransport)`, para que los errores ocurridos sin señal lleguen al volver la conexión.

### 3. Pruebas

Cada prueba nueva debe fallar sin el arreglo; se verifica desactivándolo.

**PHP (feature):**
- Lote de pagos: un ítem malo no tumba el lote; lote de 51 → 422 de forma; cada motivo de retención produce `held` sin tocar saldos; clave repetida sobre un retenido → `held`; `applied` respeta `auto_close_on_full_payment`.
- `foreign_user`: capturador de la misma empresa → retenido; de otra empresa → `invalid`.
- Visitas y gastos: conversión ×1000 con el flag (modo encendido y apagado); atribución a `captured_by_user_id`; gasto ajeno siempre pendiente de aprobación.
- Revisión: aprobar crea el pago con fecha y clave originales y marca el retenido; aprobar dos veces no crea dos pagos; rechazar exige motivo; cobrador y supervisor no acceden al recurso (policy).
- Se ajustan `SyncPaymentsTest`, `SyncExpensesTest` y `CreditParentChildTest` al nuevo comportamiento (`held` en vez de `error` para crédito no accesible, crédito padre y saldo excedido; ítems malos dentro del lote en vez de 422). `SyncPaymentRoleGuardTest` no cambia: el 403 del supervisor es de todo el lote.

**E2E (Playwright, Chromium):**
- Sin conexión (`context.setOffline(true)`): registrar un pago desde la pantalla y dejar en cola una visita "No paga" con promesa y un gasto; volver la conexión; comprobar que el servidor acepta cada ítem y que las colas quedan vacías. La conversión ×1000 se prueba en PHP, donde se puede leer el monto guardado.
- Un pago encolado con fecha de hace 10 días termina en `held_payments` y no mueve el saldo.
- La pantalla de errores sigue mostrando un ítem con error tras recargar.

**Antes de subir:** PHPStan nivel 5 sobre `app/`, Pint y `npm run build`.

### 4. Despliegue

- La migración solo crea `held_payments`; no toca datos existentes. Su `down()` se niega si la tabla tiene filas: son la única copia de cobros que el cliente sí entregó.
- Orden: compilar el frontend en local, `git pull` en el servidor, `php artisan migrate` **antes** de publicar el frontend nuevo (un teléfono con la versión nueva ya puede subir cobros que terminan en `held_payments`), regenerar cachés y recargar PHP-FPM y los workers. Los teléfonos toman la versión nueva al volver a primer plano o al recargar.
- No revertir la migración ni el frontend: los retenidos solo existen en `held_payments`, y la base local de los teléfonos ya pasó a Dexie v8 (sin el índice de `permanent_error`); un frontend viejo volvería a la consulta que nunca subió nada.
- En cuanto un cobrador entre, su teléfono sube lo atascado: lo de menos de 7 días se aplica, y lo de más, o lo que apunte a un crédito que cambió, aparece en "Cobros en revisión" con los posibles duplicados.
- Después: revisar esa bandeja con el admin y mirar en Sentry los fallos del motor durante la primera semana.

## Fuera de alcance

- **PWA-002:** con señal débil el cobro no entra a la cola (va en el siguiente cambio; depende de este).
- **PWA-008 (4):** vista de la jornada del cobrador para el supervisor, historial de ediciones de clientes.
- **PWA-011 (1) y (2):** dashboard y caché del service worker que se filtran entre usuarios.
- **PWA-003:** caché del service worker que sirve datos viejos como nuevos.
- Retener visitas o gastos: no mueven saldos de créditos y los gastos ya tienen aprobación.
- Que el cobrador vea en el teléfono el estado de sus cobros retenidos: por ahora solo el aviso al subir.
