# Envío directo tan firme como el lote

- **Fecha:** 2026-10-01
- **Rama:** `fix/pwa-directo-endurecido`
- **Origen:** revisiones de PWA-002 (PR #268, spec `2026-09-28-senal-debil-design.md`). Desde PWA-002 el teléfono manda cada cobro y cada visita primero al endpoint directo, y el lote (`/api/pwa/sync/*`) queda como red que reenvía con la misma clave. Tres huecos dejaban al directo más débil que su red.

## 1. Cobro con una clave que el lote retuvo (`PaymentController@store`)

**Hueco:** el directo buscaba la clave solo en `payments`. Un POST directo atrasado (el teléfono se rindió a los 10 s, el motor subió la fila y el lote la retuvo) se aplicaba igual, saltándose la revisión. `HeldPaymentReviewService` después se negaba a aprobar el retenido porque la clave ya existía, así que no había doble cobro, pero el retenido quedaba colgado y el pago aplicado sin revisión.

**Ahora:** tras buscar en `payments`, se busca la clave en `held_payments` de la empresa (cualquier estado: un rechazo también es una decisión). Si está: 200 con `{ message, held: true, held_payment: { id, reason, reason_label, status } }`, **sin `payment`**. Va antes del chequeo de permisos, como la búsqueda en `payments`.

El teléfono casi nunca escucha esta respuesta (ya se rindió). Si escucha, `COLAS.payments.directo.confirma` acepta `held` como confirmación: la fila se suelta, no se descuenta el saldo optimista (no se aplicó) y `PaymentView` avisa "quedó en revisión" (el `message` del servidor) y vuelve al crédito, sin recibo. Lo mismo pasa si el "Sí, es otro cobro" (§2) llega cuando la pregunta ya había vencido y el lote ya lo había retenido.

**Lo que queda:** dos envíos verdaderamente simultáneos (el lote reteniendo y el directo aplicando en el mismo instante) siguen pudiendo dejar las dos filas; lo cubre la negativa de `HeldPaymentReviewService`, como antes.

## 2. Cobro igual a otro del mismo día (decisión del usuario: preguntar)

**Hueco:** el lote retiene como `possible_duplicate` un cobro con el mismo monto, el mismo día y el mismo crédito que otro ya registrado con otra clave; el directo lo aplicaba en silencio. Desde PWA-002 importa más: tras "No se pudo confirmar con el servidor" el cobrador que repite el cobro sale con **otra clave**, y si el primero había llegado quedaba un cobro doble. Con señal mala lo retenía el lote; con señal buena pasaba. El resultado dependía de la señal.

**Servidor:**

- La regla vive en un solo lugar, `PaymentSyncService::sameDayDuplicate()`: mismo crédito, mismo monto, misma `payment_date`, otra clave (o sin clave), solo cobros (`collections()`: ni anulados ni reversas). La usan el lote y el directo.
- En el directo va **después** de las políticas de pago (`PaymentPolicyValidator`), igual que en el lote va después de `applicationBlocker()`: no se pregunta por un cobro cuyo "sí" terminaría en un 422.
- Si hay uno y no llega `confirm_duplicate: true`: **409** `{ message, possible_duplicate: true, existing_payment: { id, amount, payment_date, captured_at } }`. `captured_at` es la hora del teléfono si ese pago vino de la cola, si no la de llegada.
- El `message` está pensado para un teléfono con el bundle anterior, que no sabe preguntar y lo muestra como error: dice cuál es el pago (`pago #N`) y que, si es otro cobro, cierre y vuelva a abrir la app para confirmarlo (el SW se actualiza solo al reabrir).
- Con `confirm_duplicate: true` se registra, y queda un `Log::info` con los dos ids.
- `StorePaymentRequest` valida `confirm_duplicate` como `nullable|boolean`.
- **El lote respeta la confirmación, con límite:** si el ítem trae `confirm_duplicate === true` (booleano estricto: el lote no pasa por un FormRequest), `sameDayDuplicate()` solo cuenta los pagos iguales capturados **después** del `created_at_local` del ítem (`COALESCE(offline_created_at, created_at)`). Los anteriores son los que el cobrador pudo ver al confirmar; uno posterior no. Sin el límite: C se confirma y queda en la cola por transporte, D se confirma y se aplica, sube C → tres pagos donde iban dos. Ahora C queda retenido. Sin `created_at_local` no hay límite posible y la confirmación no salta nada. Un reloj del teléfono atrasado respecto del servidor solo puede retener de más (lo decide el admin), nunca aplicar de más. `backlogDuplicate()`, el saldo, los 7 días y el capturador ajeno se siguen mirando igual.

**PWA:**

- **La pregunta no suelta la fila.** Con un 409 `possible_duplicate` (`COLAS.payments.directo.pregunta`), `enviarOEncolar` devuelve `{ estado: 'pregunta', fila, existente }` y deja la fila guardada, sin la marca de envío y con `preguntando_hasta` (5 minutos), que `enEnvio()` respeta igual que `enviando_hasta` (con su techo: una marca más lejana que 5 minutos solo sale de un reloj atrasado y no aparta la fila). No se descuenta el saldo optimista.
- `PaymentView` abre la pregunta (`BaseSheet` centrado, `dismissible: false`: ni el fondo ni Escape la cierran; solo cuentan las dos respuestas). Título "Posible cobro repetido", texto "Hoy ya hay un pago de $X de las HH:MM. ¿Es un cobro distinto?"; la hora sale de `cuandoSeGuardo` (`utils/enCola.js`, la misma de los avisos de cola del #269: "de la 1:05", "del 29 de sept" si no es de hoy). Mientras está abierta, el aviso de cobros en cola se oculta: la fila que mostraría es la de la pregunta.
  - **"Sí, es otro cobro"** → `confirmarOtroCobro(fila, datos)`: pone `confirm_duplicate: true` en la fila, le vuelve a poner la marca de envío y la manda otra vez **con la misma clave** (seguro: el 409 dice que la clave no está ni en `payments` ni en `held_payments`). El resultado se trata igual que un primer envío: recibo, "Pago guardado" si pierde la respuesta (la fila sube con el flag y el lote la aplica), o "quedó en revisión" si mientras tanto el lote la retuvo. Si la fila ya no estaba (venció la pregunta y el motor la subió), se vuelve a agregar con la misma clave y el servidor la reconoce.
  - **"No, no registrar"** → `descartarCobro(fila)`: la fila se borra, "No se registró otro cobro." y el formulario queda como estaba.
  - **Sin respuesta** (salió de la pantalla con la pregunta abierta, la app murió, el 409 llegó con la pantalla cerrada): la fila se queda. La marca vence, el motor la sube sin la confirmación y el lote la retiene como posible duplicado: decide el admin. Si el 409 llega con la pantalla cerrada, un toast de 8 s dice "Cobro guardado para revisión: ya había uno igual hoy." Antes de este cambio el 409 soltaba la fila y el cobro se perdía en todos estos casos.
- **Con la pregunta en pantalla, el motor no toma la fila aunque la marca venza.** Con el teléfono bloqueado los temporizadores se duermen; al desbloquearlo (volver a primer plano) el motor arranca con la marca ya vencida y subiría la fila con el diálogo abierto, y un "No" posterior no tendría qué borrar. Por eso `PaymentView` la retiene en memoria (`retenerPregunta` al abrir la pregunta, `soltarPregunta` al contestar o al desmontarse) y el motor salta las claves retenidas. La memoria se va con la app: si muere, manda la marca persistente.
- **"No" sobre una fila que ya no está** (la subió otra pestaña, o no se pudo borrar): `descartarCobro` devuelve si borró algo; si no, en vez de "No se registró otro cobro." la pantalla dice "Ya se subió para revisión: avísale al administrador que es el mismo cobro."
- **Salir con la pregunta abierta** avisa con el mismo toast de "Cobro guardado para revisión: ya había uno igual hoy." (antes no decía nada).
- **IndexedDB no pudo guardar la fila** (`guardada: false` en el resultado `pregunta`): no hay red. "No" no tiene qué borrar; salir o recibir el 409 con la pantalla cerrada dice "El cobro no quedó registrado: ya había uno igual hoy. Si es otro, regístralo de nuevo." en vez de "guardado para revisión".
- La sincronización manual con un cobro en pregunta ya no dice "No hay nada pendiente" (el contador lo cuenta): "Hay un cobro esperando tu respuesta." si la pregunta está en pantalla, o "Hay un cobro guardado que se sube en unos minutos para revisión." si solo queda la marca.
- Sin señal no se pregunta nada: la fila va a la cola sin la confirmación y el lote la retiene como hasta ahora.
- Si el 409 se pierde, la fila queda sin la confirmación y el lote la retiene: el mismo resultado que sin señal.

**Con la actualización segura (#273):** la pregunta abierta cuenta como algo en curso (`retenerMientras(() => posibleDuplicado.value)` en `PaymentView`): "Actualizar" no recarga mientras está en pantalla, porque la recarga la borraría y la fila quedaría esperando sin respuesta. `confirmarOtroCobro` y `descartarCobro` (store) y `siEsOtroCobro` / `noEsOtroCobro` (pantalla) van con `conOperacionEnCurso`, como `enviarOEncolar` y `submitPayment`: no se recarga a mitad del reenvío ni del borrado, ni antes de mostrar su resultado. Con la pregunta abierta el diálogo tapa el aviso de versión nueva, así que el caso real es otro: "Actualizar" tocado durante el envío, y la pregunta llega con la actualización ya pedida. Sin la retención, la página se recargaría al terminar el reposo del envío, con la pregunta en pantalla. E2E en `actualizacion-sw.spec.js` (último paso): actualización pedida durante el envío y versión en espera; con la pregunta abierta, pasado el reposo, no recarga; contestada, se aplica. Verificado por mutación: sin el `retenerMientras`, el paso falla.

**Riesgo residual:** `sameDayDuplicate()` corre sin bloquear el crédito, en el directo y en el lote (como antes en el lote). Dos cobros iguales que llegan al mismo tiempo pueden pasar los dos el chequeo. No se cierra aquí: haría falta serializar los cobros de un crédito, y el caso que importa (repetir un cobro tras una respuesta perdida) llega segundos o minutos después, no a la vez.

## 3. Visita en carrera (`CollectionVisitController@store`)

**Hueco:** `collection_visits.idempotency_key` es único. Si dos envíos con la misma clave pasaban la búsqueda, el 1062 del segundo caía en el `catch (\Exception)` genérico y respondía 500. El teléfono lo trata como transporte y el lote después devuelve `duplicate`, así que no se perdía nada, pero no era la respuesta correcta.

**Ahora:** `catch (UniqueConstraintViolationException)` antes del genérico: se vuelve a buscar la clave y se devuelve la visita existente con `duplicate: true` (200), como `SyncController` y `ExpenseController@store`. Si no aparece (el índice es global: la clave la tiene otra empresa), el mismo 500 JSON de siempre, con `Log::error` sin el mensaje de la excepción (trae el SQL con notas y GPS) y sin relanzarla.

## Pruebas

- PHP (`DatabaseTransactions`): `tests/Feature/Pwa/DirectPaymentGuardsTest.php` (clave retenida pendiente y rechazada; la de otra empresa no cuenta; 409 con los datos del pago existente; confirmado se registra; reintento con la misma clave sigue siendo `duplicate`, también el de uno registrado con confirmación y reenviado sin ella, que prueba que la clave se busca antes del 409; en modo simplificado se compara el monto ya convertido; otro monto, otro día y pago anulado no preguntan; `confirm_duplicate` no booleano es 422; el lote aplica con la confirmación, retiene sin ella y con `"true"` de texto; la confirmación no cubre un pago igual capturado después del ítem; tampoco salta saldo, 7 días, capturador ajeno ni `backlogDuplicate`) y `tests/Feature/Pwa/CollectionVisitIdempotencyTest.php` (la carrera, con un `DB::listen` que planta la visita ganadora después del SELECT de búsqueda; la clave de otra empresa da el 500 JSON).
- E2E: un paso nuevo en `senal-debil.spec.js` (el mismo login): un cobro, el mismo monto otra vez → 409, la pregunta y la fila **sigue en la cola** con `preguntando_hasta`; "No" la borra; "Sí" con la respuesta perdida → la misma clave sale con `confirm_duplicate`, la fila y el lote lo llevan y el lote la aplica (`success`); salir de la pantalla con la pregunta abierta → la fila se queda, el motor no la manda mientras la marca está vigente y, vencida, el lote la retiene (`held`, `possible_duplicate`). Los montos del spec son distintos dentro de cada intento y el entero sale de `testInfo.retry`: con el 409, dos cobros directos iguales en el mismo crédito romperían otros pasos, también en un reintento del CI.

## Despliegue

Sin migraciones. Compatible con teléfonos en el bundle anterior: no mandan `confirm_duplicate` y reciben el 409 como un error con un mensaje que dice cómo seguir.
