# Señal débil: que una respuesta perdida no pierda ni duplique nada

- **Fecha:** 2026-09-28
- **Rama:** `fix/pwa-senal-debil`
- **Estado:** implementado en la rama `fix/pwa-senal-debil`; falta PR y despliegue.
- **Origen:** auditoría de la PWA del 2026-09-24 (informe local en `C:\Users\jredondo\credify\informes\2026-09-24-auditoria-pwa\`), hallazgo PWA-002. Depende de PWA-001 (la cola offline sube), en producción desde el 2026-09-28 (main `ae2c890`, PR #267).

## Problema

En la calle lo normal no es "sin señal" sino una señal que el teléfono reporta como conectada y que no llega al servidor. La PWA solo usa la cola cuando `navigator.onLine` es falso, así que en ese caso todo depende de que la respuesta vuelva. Leído sobre `ae2c890`:

1. **Cobro.** `PaymentView.vue:286` envía directo si `navigator.onLine`. El POST espera hasta el timeout de 30 s (`services/api.js:53`), después de hasta 5 s de GPS que se esperan antes de enviar (`PaymentView.vue:265-267`, `utils/geolocation.js`). El `catch` (`:360-393`) muestra "No se pudo conectar con el servidor" y no guarda nada. El service worker no ayuda: ignora todo lo que no sea GET.
2. **La clave del cobro nace al montar la vista** (`PaymentView.vue:250`) y solo se regenera tras un éxito (`:292`). Reintentar en la misma pantalla es seguro; salir, volver y reintentar manda otra clave, y si el primer POST sí había llegado queda un cobro doble. El camino sin señal tampoco reutiliza la clave: `queuePayment` genera una nueva (`stores/sync.js:210`).
3. **"No paga"** (`NoPaymentModal.vue:244-278`) sigue el mismo patrón, y la clave se regenera cada vez que se abre el modal (`:282-287`).
4. **Gasto.** `ExpenseCreateView.vue:198-220` sigue el mismo patrón y el POST directo no manda clave. `ExpenseController@store` (`:59-115`) no la acepta, aunque `expenses` ya tiene la columna y el índice único `(company_id, idempotency_key)` (migración `2026_04_04_000003`), que hoy solo usa el lote.
5. **Crédito y movimiento de socio** no tienen clave y el servidor no deduplica (`CreditCreateView.vue:654-689`, `CreditController@store:267-356`, `PartnerController@storeTransaction:75-145`). Una respuesta perdida seguida de un reintento crea un segundo crédito, con su cuota, su gasto de desembolso y su operación financiera (`CreditOperationService::createNewCredit:46-121`), o un segundo aporte o retiro. Ninguna regla impide un segundo crédito activo para el mismo cliente.

Con el efectivo en la mano y frente al cliente, el cobrador espera medio minuto, recibe un error y el cobro no queda en ninguna parte. Si insiste desde otra pantalla puede registrarlo dos veces.

## Principio

**Una respuesta perdida no pierde ni duplica una operación.**

- Lo de campo (cobro, visita "No paga", gasto) queda guardado en el teléfono **antes** de salir y solo se suelta cuando el servidor responde.
- Lo que no puede ir a la cola (crédito, movimiento de socio) lleva una clave que hace seguro reintentar: el servidor devuelve el original en vez de crear otro.

## Diseño

### 1. Operaciones de campo: primero a la cola, después el envío

#### 1.1 `enviarOEncolar(tipo, datos)` en `stores/sync.js`

Una sola función, usada por las tres pantallas. `tipo` es `payments`, `visits` o `expenses`. El POST directo de cada tipo (endpoint y objeto que confirma la respuesta) vive en la configuración de la cola (`COLAS[tipo].directo`), junto al endpoint del lote: las pantallas solo pasan los datos.

1. Arma la fila: `datos` + `idempotency_key` nueva + `created_at_local` + `captured_by_user_id`; con señal, además `enviando_hasta = Date.now() + 15000`.
2. La escribe en IndexedDB. Desde ahí el registro existe en el teléfono pase lo que pase.
3. **Sin señal** (`navigator.onLine` falso, o sin token: `conSenal = navigator.onLine && token`; sin sesión el POST sería un 401 seguro y el interceptor recargaría en el login antes de mostrar "Guardado", así que cuenta como sin señal y el motor sube la fila al volver a entrar): no intenta enviar. Descuenta el saldo optimista (solo cobros) y devuelve `{ estado: 'en_cola' }`. Es lo que hoy hacen `queuePayment`, `queueVisit` y `queueExpense`.
4. **Con señal:** envía `datos` con la misma clave al endpoint directo. El resultado se clasifica igual que en el motor (`esFalloDeTransporte`, que pasa a `utils/transporte.js` para compartirla con los formularios de 2.4):

| Resultado del envío directo | Fila | Devuelve | Pantalla |
|---|---|---|---|
| 2xx con el objeto esperado (incluye `duplicate: true`) | se borra | `{ estado: 'enviado', respuesta }` | recibo como hoy |
| 2xx sin el objeto esperado (portal cautivo, proxy, página de error) | queda | `{ estado: 'en_cola' }` | "Guardado" |
| 4xx salvo 401, 408, 419 y 429 | se borra | relanza el error | el error de hoy |
| sin respuesta, timeout, 5xx, 401, 408, 419, 429 | queda | `{ estado: 'en_cola' }` | "Guardado" |

- Un 4xx real es seguro de soltar: el servidor no lo registró y el cobrador lo corrige en el momento (monto mayor al saldo, crédito no asignado).
- Cuando la fila queda (`dejarParaElMotor`): se quita la marca y se descuenta el saldo optimista (solo cobros), salvo que la fila ya no esté: si `update()` devuelve 0, la marca venció en pleno envío y el motor ya la tomó; el servidor la tiene y el snapshot de esa ronda trajo el saldo real, así que descontar de nuevo lo contaría dos veces. No se pide una ronda inmediata: la señal acaba de fallar, y los disparadores de PWA-001 (volver a primer plano, volver la señal, cada 3 minutos) la suben.
- Tras un éxito directo sí: la señal acaba de responder, y si queda algo más en la cola se pide una ronda en segundo plano (`pedirRonda()`).
- La fila se borra con `soltar()` (por `localId` y comprobando la clave), como en el motor.
- "El objeto esperado" lo define cada pantalla: `payment` para cobros, `visit` para visitas, `expense` para gastos.

**El motor salta las filas en envío:** `sincronizarCola` lee `!x.permanent_error && !enEnvio(x)`. `enEnvio(fila)` es verdadera si `enviando_hasta` es un número y falta entre 0 y `MARCA_ENVIO_MS` (los 15 s de la marca) para que venza: una marca más lejana que una marca entera solo sale de un reloj que después se atrasó, no protege ningún envío en curso y la fila vuelve al motor. Así el lote nunca manda lo que una pantalla está enviando en ese momento. Si la app muere a mitad de envío, la marca vence sola a los 15 s y la siguiente ronda la sube con la misma clave; si ese primer envío había llegado, el servidor responde `duplicate` y la fila se suelta.

`enviando_hasta` es un campo común, no un índice: la base de Dexie no cambia de versión.

**Timeout de 10 s** en el POST directo de las tres pantallas (`{ timeout: 10000 }` por request; el default de 30 s de `api.js` sigue para el resto). Un cobro tarda menos de un segundo en el servidor; si en 10 s no volvió nada, esperar más no ayuda al cobrador y reintentar es seguro por la clave. Los 15 s de la marca son ese timeout más un margen.

`queuePayment`, `queueVisit` y `queueExpense` dejan de existir: su trabajo es la rama sin señal de `enviarOEncolar`. Una sola puerta a la cola.

**Si IndexedDB no puede guardar** (almacenamiento lleno o bloqueado), con señal se envía directo como antes de este cambio, y el fallo va a Sentry; sin señal no hay dónde guardarlo y la pantalla muestra el error: el `Error` de IndexedDB sale marcado con `sinGuardar = true`, y solo con esa marca la pantalla de cobro dice "No se pudo guardar el cobro en el teléfono ni enviarlo. No quedó registrado: anótalo y vuelve a intentarlo.". El texto no afirma una causa ("no hay señal"): `sinGuardar` también salta cuando el teléfono está en línea pero sin sesión (sin token), que cuenta como sin señal. Cualquier otro fallo sin respuesta clara (sin respuesta tras fallar IndexedDB, un 2xx sin el cobro) dice "No se pudo confirmar con el servidor. Revisa el historial de pagos antes de repetirlo.": el servidor pudo haberlo registrado y no se invita a repetirlo a ciegas. Sin esto, "primero a la cola" sería una regresión: un teléfono con la base rota no podría registrar nada ni con buena señal.

#### 1.2 GPS en paralelo

Hoy se esperan hasta 5 s de GPS al tocar "Registrar". Pasa a pedirse al abrir la pantalla (`PaymentView`, al montar) o el modal (`NoPaymentModal`, al abrirse), y al enviar se espera esa misma promesa. `getCurrentPosition` nunca rechaza y, concedido el permiso, resuelve dentro de sus 5 s. Las dos pantallas comparten dos funciones de `utils/geolocation.js`: `pedirPosicion(opciones)` pide la posición y, si no hubo ninguna a tiempo, reintenta una vez con la última conocida (`maximumAge` de 5 minutos) mientras el cobrador escribe el monto; y `esperarPosicion(promesa, topeMs = 6000)`. Un permiso sin responder no cuenta para ese timeout, así que al enviar se espera esa promesa con un tope de 6 s (`esperarPosicion`): lo peor al enviar son 6 s y sigue sin posición ("Ubicación no capturada"). Lo normal es que ya esté listo cuando el cobrador terminó de escribir el monto. Sin señal se mantiene `maximumAge` de 5 minutos, como hoy (solo en la pantalla de pago; el modal "No paga" usa las opciones por defecto).

#### 1.3 Registrar pago (`PaymentView.vue`)

- `enviar` = `POST /pwa/payments` con `{ ...datos, idempotency_key: clave }` y timeout de 10 s. Objeto esperado: `respuesta.data.payment`.
- Se elimina `paymentIdempotencyKey`: la clave nace al enviar y vive en la fila.
- `enviado` → lo de hoy (actualiza la caché local con la respuesta y va al recibo).
- `en_cola` → la misma pantalla de "Pago Guardado" que hoy sin señal (`payment-success` con `offline: true`) y el toast "Pago guardado en el teléfono". Con la pantalla ya cerrada el toast sale igual (el cobrador se entera de que quedó guardado o enviado) y solo se omite la navegación.
- **Una vez entregado el resultado a la navegación, "Confirmar" sigue apagado** (solo se rehabilita si la navegación falla): entre el `push` y el cambio de pantalla seguiría tocable, y el aviso de cobros en cola mostraría la fila recién guardada. Tras un `enviado`, un fallo de la caché local no se trata como error de pago (va a Sentry con `paso: cache_post_cobro`): el servidor ya lo registró y el cobrador lo repetiría.
- El aviso de "sin conexión" del formulario dice: "Sin conexión — el pago se guarda en el teléfono y se sube solo".
- **Texto del aviso en `PaymentSuccessView`:** de "Se sincronizará cuando tengas conexión" a **"Guardado en el teléfono. Se sube solo cuando alcance la señal; no hace falta registrarlo otra vez."** Con señal débil el teléfono cree estar conectado, y el texto viejo invitaba a volver a cargarlo.
- **Abrir la pantalla con señal débil:** la carga del crédito espera como mucho 8 s; sin respuesta usa la caché del teléfono (lo mismo que sin señal) en vez de mostrar "Crédito no encontrado" tras 30 s. Sin esto el resto de 1.3 no se alcanza en el escenario que motiva PWA-002. Si el crédito tampoco está en la caché, la pantalla dice "No hubo respuesta del servidor y este crédito no está guardado en el teléfono." con un botón "Reintentar"; un 404/403 sigue mostrando "Crédito no encontrado".
- **Aviso nuevo en el formulario:** si la cola tiene cobros propios para este crédito (`ownPendingPayments` con el mismo `credit_id`), se muestra arriba del formulario:
  - uno: "Ya hay un cobro de $X de las HH:MM guardado para este crédito, esperando señal. Si es el mismo, no lo registres otra vez."
  - varios: "Ya hay N cobros guardados para este crédito ($X en total), esperando señal. Si alguno es este mismo, no lo registres otra vez."
  - Cuentan también los que se están enviando (con la marca `enEnvio` vigente). El envío de esta misma pantalla ya lo oculta el estado de envío (`submitting`, y `entregado` hasta que llega la navegación), así que la fila en envío que se ve acá la mandó una pantalla que el cobrador ya dejó (volvió atrás durante "Registrando…"), otra pestaña o la app reabierta dentro de los 15 s: justo la que volvería a registrar, y el POST directo no retenía duplicados del día, así que sería un cobro doble real (desde `2026-10-01-directo-como-el-lote-design.md` el directo pregunta antes de aplicar un cobro igual del mismo día). No cuentan los que agotaron reintentos (`permanent_error`): esos no esperan señal, necesitan una acción. Si hay, el mismo aviso suma: "N cobro(s) de este crédito no se pudo/pudieron subir: revísalo/revísalos en Errores de sincronización antes de registrar otro."

  No bloquea: puede haber dos cobros reales. Avisa porque el saldo del servidor todavía no incluye ese cobro y el cobrador creería que no quedó. Si igual lo carga con el mismo monto el mismo día, el lote ya lo retiene como "posible duplicado" (PWA-001), y el envío directo pregunta si es otro cobro (`2026-10-01-directo-como-el-lote-design.md`).
- **Errores** (`error` en pantalla y toast de 8 s: los mensajes de incertidumbre son de dos frases). Rechazo real del servidor: su mensaje. `sinGuardar`: "No se pudo guardar el cobro en el teléfono ni enviarlo. No quedó registrado: anótalo y vuelve a intentarlo." (sin afirmar una causa; ver 1.1). Cualquier otro fallo: "No se pudo confirmar con el servidor. Revisa el historial de pagos antes de repetirlo."

#### 1.4 "No paga" (`NoPaymentModal.vue`)

- `enviar` = `POST /pwa/visits` con la clave y timeout de 10 s. Objeto esperado: `respuesta.data.visit`.
- Se elimina `visitIdempotencyKey` y su regeneración al abrir. El GPS se pide al abrir el modal (`pedirPosicion()`, con los valores por defecto de `getCurrentPosition`) y al registrar se espera esa promesa con el mismo tope de 6 s que el cobro (`esperarPosicion(gpsListo ?? pedirPosicion())`); ambas funciones son las compartidas de `utils/geolocation.js` (§1.2).
- `en_cola` → toast "Visita guardada en el teléfono" (la misma redacción que el cobro) y `emit('success', { offline: true })`, como hoy.
- **Errores**, con la misma regla que el cobro: "No quedó registrada" solo cuando es seguro.
  - Rechazo real del servidor (una respuesta que no es de transporte: un 4xx que no sea 401, 408, 419 ni 429): su `message`, o el primer error de validación, como hoy; sin ninguno de los dos, "Error al registrar la visita".
  - `sinGuardar` (sin señal —o sin sesión— e IndexedDB no pudo guardar): "No se pudo guardar la visita en el teléfono ni enviarla. No quedó registrada: anótala y vuelve a intentarlo." No afirma una causa: `sinGuardar` también salta sin token.
  - Cualquier otro fallo (sin respuesta, un 5xx o un 401/408/419/429 con respuesta, un 2xx sin la visita, un error inesperado): "No se pudo confirmar con el servidor: puede que la visita sí haya quedado. Si la repites, podría quedar dos veces." La PWA no tiene una pantalla con las visitas de un crédito, así que el mensaje no manda a revisarlas. Si además no hay `err.request` (un fallo inesperado, no de red), va a Sentry con `paso: submit_visita`.
  - "Rechazo real" se decide por la regla de transporte (`esFalloDeTransporte`), no por si la respuesta trae un mensaje: con IndexedDB roto y señal, el store relanza también los errores de transporte que sí tienen respuesta, y un 5xx o un 524 pudo haber registrado la visita. La pantalla de cobro aplica la misma regla.
  - El toast de error dura 8 s (los mensajes de incertidumbre son de dos frases), como en el gasto, el crédito y el socio; y también en la pantalla de cobro (1.3).
- **El modal no se cierra mientras se envía** (hasta 6 s de GPS más 10 s de POST): la X queda deshabilitada y el fondo y Escape no lo cierran (`dismissible` del `BaseSheet`). Reabierto, el formulario en blanco recibiría el cierre o el error del envío anterior.

#### 1.5 Registrar gasto (`ExpenseCreateView.vue` y `ExpenseController@store`)

- `enviar` = `POST /pwa/expenses` con la clave y timeout de 10 s. Objeto esperado: `respuesta.data.expense`.
- `en_cola` → toast "Gasto guardado en el teléfono" (la misma redacción que el cobro y la visita). El aviso de "sin conexión" del formulario dice: "Sin conexión — el gasto se guarda en el teléfono y se sube solo."
- **Doble toque:** `submit()` no hace nada si ya hay un envío en curso: cada llamada a `enviarOEncolar` genera una clave nueva, así que un segundo toque mientras el primero sale sería otro gasto.
- **Regreso automático:** tras registrar, la pantalla vuelve sola 1,8 s después (`router.back()`); el temporizador se cancela al desmontarse la pantalla y, si el envío termina cuando la pantalla ya se cerró (el temporizador todavía no existe), no se crea, para que ese `back()` no saque al cobrador de la pantalla a la que ya se fue. El aviso de resultado sale igual, por el toast global (`useToast`), para que el cobrador se entere aunque se haya ido; "registrado y aprobado" o "pendiente de aprobación" se elige por `requires_approval` de la respuesta del servidor, y solo sin ese dato por el rol del cliente.
- **Errores**, con la misma regla que el cobro y la visita: "No quedó registrado" solo cuando es seguro. El mensaje sale en el toast global (8 s, los de incertidumbre son de dos frases) y se queda escrito sobre el botón hasta el próximo envío.
  - Rechazo real del servidor (una respuesta que no es de transporte según `esFalloDeTransporte`: un 4xx que no sea 401, 408, 419 ni 429): su `message`, o "Error al registrar el gasto. Intenta de nuevo.".
  - `sinGuardar` (sin señal —o sin sesión— e IndexedDB no pudo guardar): "No se pudo guardar el gasto en el teléfono ni enviarlo. No quedó registrado: anótalo y vuelve a intentarlo." No afirma una causa: `sinGuardar` también salta sin token.
  - Cualquier otro fallo (sin respuesta, un 5xx o un 401/408/419/429 con respuesta, un 2xx sin el gasto, un error inesperado): "No se pudo confirmar con el servidor: puede que el gasto sí haya quedado. Si lo repites, podría quedar dos veces." Es la misma redacción de la visita "No paga": no manda a "Mis gastos" (`/pwa/expenses`, `MyExpensesView`) porque esa pantalla solo tiene enlace en el inicio del cobrador y supervisores y administradores no llegan a ella desde la app. Si además no hay `err.request` (un fallo inesperado, no de red), va a Sentry con `paso: submit_gasto`.
- **Servidor:**
  - Valida `idempotency_key` como `nullable|uuid`.
  - Tras validar, si llega una clave que la empresa ya tiene: 200 con `{ message: 'Este gasto ya fue registrado.', duplicate: true, expense: {…} }`, el mismo formato de `expense` que una creación.
  - La clave se pasa a `FinanceAutoLogger::logExpense`, que ya la guarda.
  - Carrera: si dos envíos con la misma clave pasan la búsqueda, el índice único `expenses_company_idempotency_unique` frena el segundo; se captura la violación y se devuelve el original como duplicado, igual que `PaymentController@store`.
  - Sin clave, como hoy: los teléfonos que todavía no cargaron el bundle nuevo no se rompen.

### 2. Operaciones sin cola: crédito y movimiento de socio

No van a la cola: el crédito necesita en el momento el límite del plan y las validaciones del cliente, y el movimiento de socio es solo del admin y no es de campo. Mantienen el timeout de 30 s.

#### 2.1 Migración

`idempotency_key` (uuid, nullable) con índice único `(company_id, idempotency_key)` en:

| Tabla | Índice |
|---|---|
| `credits` | `credits_company_idempotency_unique` |
| `capital_contributions` | `capital_contributions_company_idempotency_unique` |
| `partner_withdrawals` | `partner_withdrawals_company_idempotency_unique` |

`down()` quita índices y columnas. Los modelos (`Credit`, `CapitalContribution`, `PartnerWithdrawal`) suman el campo a `$fillable`.

#### 2.2 Crédito (`POST /pwa/credits`)

- `StoreCreditRequest` acepta `idempotency_key` como `nullable|uuid`.
- Orden en `CreditController@store`:
  1. Permiso (`canCreateCredits`), como hoy.
  2. Si llega clave y la empresa ya tiene un crédito con ella: 200 con el mismo cuerpo que la creación más `duplicate: true` (el formateo pasa a un método privado que usan los dos caminos). **Antes del límite del plan:** si no, el reintento de un crédito ya creado podría rechazarse por "cupo alcanzado" por culpa de ese mismo crédito.
  3. Límite del plan y creación, como hoy. `createNewCredit` guarda `idempotency_key` en `Credit::create`.
- `add_to_route` solo se aplica al crear, no a un duplicado.
- **Carrera:** el segundo `Credit::create` viola el índice único y su transacción entera vuelve atrás (cuotas, gasto de desembolso y operación financiera viven en el mismo `DB::transaction`, dentro del de `runGuardedCreate`). La violación se captura antes del `catch (\Exception)` genérico y se devuelve el original como duplicado. No puede haber doble desembolso.
- Sin clave, como hoy (Filament y bundles viejos). El panel Filament no se toca.

#### 2.3 Movimiento de socio (`POST /pwa/partners/{id}/transaction`)

- Valida `idempotency_key` como `nullable|uuid`.
- Tras el chequeo de admin y la validación, y **antes del chequeo de saldo**, busca la clave en `capital_contributions` y en `partner_withdrawals` de la empresa. Si existe: 200 con `{ message, duplicate: true, transaction: {…}, partner: {…} }`, el mismo formato que una creación. Antes del saldo porque, si no, el reintento de un retiro se rechazaría con "supera el saldo del socio" por culpa del primer intento.
- **Segunda búsqueda justo antes del 422 de saldo** (`via: 'saldo'`): si otro envío con la misma clave registró su retiro (y bajó el saldo) entre la primera búsqueda y el chequeo, ese 422 no es un rechazo real, y el cobrador leería "supera el saldo" por un retiro que sí existe. Se vuelve a buscar la clave antes de rechazar y, si aparece, se devuelve el original como duplicado. El duplicado también responde con el socio del movimiento, no con el de la URL, por si una clave se reusara con otro socio.
- `createContribution` y `createWithdrawal` guardan la clave en el aporte o en el retiro. El gasto contable del retiro se crea en la misma transacción: una carrera deshace los dos.
- Carrera: como en 2.2, la violación del índice se captura y se vuelve a buscar la clave (`via: 'carrera'`): si el movimiento del otro envío aparece, se devuelve como duplicado; si no, se relanza la excepción. El registro de log de cada duplicado lleva por qué camino se atrapó (`busqueda`, `saldo` o `carrera`).

#### 2.4 PWA: la clave vive en el teléfono hasta que el servidor confirma

`CreditCreateView.vue` y `PartnerTransactionView.vue` (vía `stores/partners.js`):

- **Dónde y cuánto dura:** la clave se crea en el primer envío y se guarda en `localStorage` como `{ clave, dia }` (el día del teléfono), atada a la operación **y al usuario**:
  - crédito: `credify:clave:credito:<user_id>:<client_id>`
  - socio: `credify:clave:socio:<user_id>:<partner_id>:<contribution|withdrawal>`
  - Sobrevive a cerrar la app a la fuerza y a volver a entrar. Vence al terminar el día: una clave de otro día se reemplaza por una nueva, y cada lectura purga las claves vencidas para que no se acumulen.
  - Todo va en `try/catch`. Si `localStorage` no está disponible (modo privado), la clave vive en memoria: cubre al menos el reintento en el mismo formulario.
- **Cuándo se suelta:** solo con la confirmación del servidor: un 2xx que trae el registro (crédito o movimiento), sea creado o duplicado. Un 2xx sin el registro no confirma nada y conserva la clave.
  - **Crédito:** confirmado el 2xx, la clave se suelta cuando la navegación al crédito llega (el `push` resuelve sin fallo) o, si no, cuando se desmonta la pantalla: si el usuario se va durante el fundido y esa navegación se cancela, conservar la clave haría que el próximo crédito del mismo cliente ese día volviera como "ya estaba creado". Soltar dos veces es inocuo. Si el `push` falla con la pantalla todavía montada, la clave se conserva y el botón se rehabilita: un segundo toque devuelve el crédito ya creado como duplicado, que es seguro.
  - **Socio:** la clave se suelta en el mismo instante del 2xx confirmado (no hay navegación que esperar para decidirlo).
  - Un **4xx tampoco la suelta**: la validación corre antes de buscar la clave, así que un rechazo no prueba que el primer envío no haya creado el registro. Conservarla no crea registros de más: si con esa clave no existe nada, el servidor crea con normalidad.
  - Se conserva también ante un fallo de transporte (sin respuesta, timeout, 5xx, 401, 408, 419 o 429, la misma clasificación que en 1.1).
- **Mensajes**, en este orden (los dos formularios igual):
  1. Sin enviar (`sinEnviar`, solo socio; el store lo marca al lanzar el error de "offline"): "Sin conexión: el movimiento no se envió."
  2. 4xx real: los errores del servidor como hasta ahora (campos y `message`).
  3. Con respuesta de transporte: texto fijo en español con el estado, "El servidor no pudo confirmar el crédito (error 503). Puedes reintentar: si ya se había creado, no se duplica." (para socio, "el movimiento… registrado"). No se pega el texto en inglés del servidor.
  4. Sin respuesta: "No hubo respuesta del servidor. Puedes reintentar: si ya se había creado, no se duplica." Si además no hay ni `request` (un fallo inesperado), va a Sentry con la etiqueta `paso: submit_credito` o `submit_socio`.
  - Los avisos de incertidumbre en toast duran 8 s.
- **Duplicado:** un aviso de 8 s dice que no se creó otro y muestra los valores del ORIGINAL que trae la respuesta:
  - crédito: "Este crédito ya estaba creado (el primer envío): $X en N cuotas. No se creó otro." y lleva a ese crédito.
  - socio: "Este movimiento ya estaba registrado (el primer envío): $X. No se registró otro." y vuelve a Socios.
  - Una creación normal conserva sus mensajes de siempre.
- **Crédito, doble toque:** el botón "Crear Crédito" sigue apagado desde que el resultado se entrega a la navegación (patrón `entregado` de `PaymentView`) y solo se rehabilita si la navegación falla; si no, entre el `push` y el cambio de pantalla un segundo toque crearía otro crédito.
- **Socio, si el admin ya salió:** el aviso local (`toast`/`serverError`) muere con la pantalla, así que en ese caso el resultado, éxito o error, sale por el toast global. Tampoco se arma la vuelta a Socios.
- **Socio, `stores/partners.js`:** actualizar la lista local tras el POST va en su propio `try/catch` (`console.warn` y Sentry con `paso: cache_post_socio`): un fallo local con el servidor ya respondido no se confunde con "sin respuesta".
- Si el usuario cambió el monto antes de reintentar y el primero había llegado, recibe el original con su monto real: es lo que pasó, y el aviso lo dice.
- Si tras un "sin respuesta" el usuario quiere de verdad un segundo crédito para el mismo cliente, el primer intento le devuelve el original como duplicado (suelta la clave) y el siguiente crea uno nuevo. Es un toque extra, con un mensaje explícito, a cambio de no duplicar desembolsos.

### 3. Casos borde

- **App cerrada o matada a mitad de envío:** la fila ya está guardada y su marca vence a los 15 s. Lo que falta es el descuento optimista del saldo: la caché muestra el saldo viejo hasta la próxima sincronización. El aviso de 1.3 cubre que no se vuelva a registrar.
- **Llega la respuesta pero falla borrar la fila** (error de IndexedDB): la fila queda, el motor la manda, el servidor responde `duplicate` y se suelta. No hay doble descuento de saldo: en el camino de éxito el saldo sale de la respuesta.
- **401 a mitad de envío:** el interceptor manda al login con recarga completa. La fila ya estaba guardada y sube al volver a entrar (PWA-001 sincroniza al entrar).
- **Mientras envía, el indicador muestra "1 pendiente"** hasta 10 s. Es cierto: no está confirmado.
- **Otras pestañas, reloj cambiado, filas encoladas antes de este despliegue (sin marca):** lo peor es un doble envío con la misma clave, y el servidor deduplica.
- **Red lenta que sí aplica el cobro al segundo 12:** el cobrador ve "Guardado" en vez del recibo; luego el motor recibe `duplicate` y el cobro aparece en el historial. Es el costo, elegido, de no hacerlo esperar.
- **Reintento que ya no pasa la validación:** la búsqueda de la clave va después de validar (como en el cobro). Si el reintento cruza la medianoche con `start_date` de ayer, o la empresa cambió sus límites entretanto, recibe el 422. Para crédito y movimiento de socio este caso queda cerrado: el 422 **no suelta la clave**, así que el reintento corregido sale con la misma y, si el primer envío sí había creado el registro, el servidor devuelve el original. (Solo queda el vencimiento de la clave al terminar el día: cruzar la medianoche con la respuesta perdida genera una clave nueva.)
- **Teléfono compartido:** el servidor solo empareja empresa y clave, así que la clave va atada al usuario (`<user_id>` en el nombre): la clave pendiente de A no la hereda B al entrar en el mismo teléfono.
- **Carrera con el cupo del plan:** si dos envíos con la misma clave chocan y el primero llena el cupo, el segundo no recibe el 422 del límite sino el crédito ya creado: la clave se vuelve a buscar antes de responder el rechazo.

### 4. Pruebas

**PHP (PHPUnit, con `DatabaseTransactions` como el resto de la suite):**

- Gasto: misma clave dos veces → un gasto, el segundo 200 `duplicate`; sin clave → como hoy (dos gastos); la misma clave en otra empresa no choca; una carrera simulada (fila con la clave insertada entre la búsqueda y la creación) → devuelve el original.
- Crédito: misma clave dos veces → un crédito, **un** gasto de desembolso, **una** operación financiera; reintento con el límite del plan ya lleno → `duplicate`, no 422 (y un crédito nuevo con otra clave sí recibe el 422); sin clave → como hoy. (`add_to_route` no se aplica al duplicado, pero no lleva test: `appendCredit` ya es idempotente y el test no probaría nada.)
- Socio: aporte dos veces → un aporte; retiro dos veces → un retiro y **un** gasto; reintento de un retiro que dejó el saldo en cero → `duplicate`, no "supera el saldo"; sin clave → como hoy.
- Esquema: las tres columnas y sus índices únicos existen, y el índice de `credits` rechaza una segunda fila con la misma clave en la empresa (y la acepta en otra). No se corre `up()`/`down()` en el test: la base de los tests es la de desarrollo y un DDL hace commit implícito.

**E2E (Playwright, junto al spec `cola-offline`):**

- Señal débil: se aborta el POST del cobro con `navigator.onLine` verdadero → "Pago Guardado" con el texto nuevo y la fila en IndexedDB con su clave; al volver la red, el servidor tiene **exactamente un** cobro con esa clave. Esta prueba falla con el código de hoy (muestra error y no guarda nada).
- Respuesta perdida: el POST llega al servidor (`route.fetch`) pero se descarta su respuesta → tras la ronda del motor hay exactamente un cobro. Igual para una visita y para un gasto.
- Camino feliz (el más común en producción, sin cortes ni rutas): con respuesta, cobro, visita y gasto se registran por el envío directo, se ve el resultado normal ("Pago Registrado", "Visita registrada", "Gasto registrado…"), la cola queda vacía y ninguna ronda del motor lleva su clave en un lote.
- Marca de envío: una fila con la marca vigente no sale en la ronda; una con la marca vencida (lo que deja una app muerta a mitad de envío) sí; vencida la primera, la siguiente ronda también la sube. Se prueba inyectando las filas y no recargando: el token vive solo en memoria y cada recarga cuesta un login.
- Crédito: respuesta perdida → el aviso de "sin respuesta" y la clave guardada en `localStorage`; se sale a Inicio y se vuelve al formulario (otra pantalla, la misma operación); el reintento sale con la **misma** clave, lleva al crédito que ya existía y muestra el aviso "ya estaba creado (el primer envío)"; al confirmar, la clave ya no está en `localStorage`. La prueba comprueba la misma clave, el mismo crédito y los dos cuerpos enviados; que el servidor tenga un solo crédito y un solo desembolso lo cubren las pruebas PHP.
- Aviso: con un cobro en cola, reabrir la pantalla de pago de ese crédito muestra el aviso.
- Abrir la pantalla con señal débil: se cuelga la carga del crédito (`GET /pwa/credits/:id`) → la pantalla de pago abre en menos de 15 s desde la caché, con el cliente y el botón "Confirmar Pago" habilitado.
- Chromium para todo; WebKit solo si entra en el presupuesto de logins del CI (5 por minuto).

**Antes de pushear:** PHPStan nivel 5, Pint, build y la suite completa.

### 5. Despliegue

- **Orden del despliegue** (`deploy.sh`, tras el respaldo de la BD): `git pull` → `composer install` → cachés (`config`, `route`, `view`, `event`) → `filament:cache-components` → **`migrate --force`** → publicar el frontend (`public/build` y `pwa-sw.js`) → recargar php-fpm. La migración va antes de publicar el bundle nuevo: si falla, `set -e` corta con los teléfonos todavía en el bundle anterior. Son tres columnas nullable con índice único; con todos los valores en NULL es rápida.
- **Ventana entre el `pull` y la migración** (los segundos que toman `composer`, las cachés y `filament:cache-components`): el código nuevo ya está en disco y las columnas todavía no, así que un crédito o un movimiento de socio creado en ese lapso da 500 (columna `idempotency_key` desconocida). No se pierde nada ni se duplica: el 500 es un fallo de transporte para el teléfono, que conserva la clave (crédito, socio) o la fila en la cola (cobro, visita, gasto) y reintenta cuando la migración ya corrió. Se cura solo.
- Compatible en los dos sentidos: un teléfono con el bundle viejo no manda clave y funciona como hoy.
- **A diferencia de PWA-001, se puede revertir:** Dexie no sube de versión y el `down()` de la migración solo quita las columnas de clave. Con dos advertencias:
  - **El código se revierte ANTES de correr el `down()`.** Con el código nuevo y las columnas ya quitadas, cada creación de crédito y de movimiento de socio da 500 por una columna desconocida (la clave va en el `create`).
  - **Servidor revertido y teléfonos con el bundle nuevo** (los teléfonos no se revierten solos): el `ExpenseController` viejo ignora la clave del POST directo. Si esa respuesta se pierde y el motor reenvía el gasto por el lote, el servidor no encuentra la clave y puede crear dos gastos. Con cobros y visitas no pasa: sus controladores ya guardaban la clave antes de este cambio y esta rama no los toca.
- CHANGELOG, y el informe de la auditoría marca PWA-002 como arreglado.

## Fuera de alcance

- **Clientes:** la cédula es única por empresa (`clients (company_id, identification)`), así que un reintento recibe "Ya existe un cliente con esta cédula" en vez de duplicar.
- **Anular pago, aprobar o rechazar gastos:** no crean registros; un reintento encuentra el estado ya aplicado ("Este pago no se puede anular (ya anulado…)", "Este gasto ya fue procesado").
- **Cola para créditos o movimientos de socio,** y operar socios sin señal.
- **Service worker:** sigue sin interceptar POST; no hace falta.
- **Clave persistente para cobros, visitas y gastos:** la cola ya la guarda desde antes de enviar.
