# PROPUESTA — Baja parcial por `SIN_CUPO` después de confirmar el pago

> **Estado:** propuesta de diseño, pendiente de aprobación. **NO implementada.**
> **Fecha del análisis:** 19-ago-2026.
> **Alcance:** qué hacer cuando Reservaciones V2 responde `SIN_CUPO` sobre una reservación que ya está en `Confirmando`, y solo **algunos** tipos de habitación no cupieron.
> **Proyectos:** `CL032BACK` (portal, backend), `CL032FRONT` (portal, front). `Reservaciones V2` **no se toca**.
> **Decisión de negocio ya tomada:** ajuste automático + aviso, con retorno del socio al wizard de selección de habitaciones.

---

## 1. El síntoma

Pantalla `/reservaciones/reservacion/pago`, tras pulsar **Confirmar reservación**:

> **No pudimos confirmar tu reservación**
> La disponibilidad cambió mientras confirmábamos y ya no alcanzó para tu reservación. No se realizó ningún cargo.
> `[ Elegir otras fechas ]`

El socio pierde **toda** la reservación aunque solo una de sus 3 habitaciones se haya quedado sin cupo. Al pulsar el botón se hace `store.reset()` y vuelve a empezar de cero: fechas, habitaciones y notas.

**El comportamiento esperado** —y el que ya existe cuando el mismo veredicto llega *antes* de pagar, mientras se habilita el botón— es quitar solo la habitación afectada y dejar viva la reservación con el resto.

## 2. La traza, verificada en código

```
Socio pulsa Confirmar
 └─ CL032 · ReservacionService::confirmar()
     Temporal → Confirmando; snapshot 'Reservado' con 3 habitaciones → Reservaciones V2
     └─ Reservaciones V2 · ReplicarReservacionEnLinea::handle()
         └─ revisarCupoConCandado()  → el tipo X no cabe
             └─ return VEREDICTO_SIN_CUPO + tiposSinCupo:[X]
                ⚠ SALE ANTES de llamar a Reservacion::replicarSnapshot()
                 └─ POST /reservaciones/replicacion/veredicto → CL032
                     └─ ReplicacionController::resolverSinCupo()
                         $parcial = (estado === Temporal && tiposSinCupo !== [])
                                  = FALSE  ← porque el estado es Confirmando
                         └─ ReservacionService::cancelar()
                            Se pierden las 3, incluidas las 2 que sí cabían.
```

Todo depende de **una sola condición**, `ReplicacionController::resolverSinCupo()` (~L274):

```php
$parcial = $reservacion->reservacionEstado === Reservacion::ESTADO_TEMPORAL
    && $tiposSinCupo !== [];
```

`Temporal` → parcial · `Confirmando` → cancelación total. **Nada más.** La maquinaria de la baja parcial ya existe, está probada y es la misma que usaría el camino nuevo: `ReservacionService::quitarHabitacionesDeTipos()`.

## 3. Dos hallazgos que hacen viable el cambio

### 3.1 Cuando Reservaciones V2 responde `SIN_CUPO` no escribe absolutamente nada

El `return` con el veredicto ocurre **antes** de `Reservacion::replicarSnapshot()`, dentro de la misma transacción. Consecuencias:

- El hold previo del portal **sigue vivo en el privado**, en `Temporal`, y **sigue ocupando cupo** — el scope de ocupación cuenta `reservacionEstado ∈ (Reservado, Finalizado, Temporal)`.
- Las habitaciones que sí cabían **nunca se perdieron allá**: están apartadas en ese preciso momento.
- No se selló maestría (`sellarMaestriaPrivada()` solo corre con `confirmar: true`), así que el portal sigue siendo dueño y puede reescribir el snapshot.

**Hoy el portal cancela y libera cupo que el hotel todavía le está guardando**, y lo hace en el peor momento posible: cuando el socio ya pasó todos los filtros.

### 3.2 La premisa que justificaba cancelar todo ya no se cumple

El comentario de `resolverSinCupo()` dice:

> *PAGO (`Confirmando`): se cancela todo. Una venta cobrada no admite entrega parcial; o se honra completa o se devuelve.*

Correcto **como principio**, pero hoy el portal **no cobra**: `ConfirmarFormRequest` solo acepta `Transferencias`, el flujo de tarjeta aún no existe, y el propio mensaje al socio dice *"No se realizó ningún cargo"*. Con transferencia el socio paga después, contra un importe que el portal todavía puede recalcular.

> ⚠️ **Gancho para el futuro.** Cuando entre el cobro con tarjeta, esta rama **debe revisarse**: ahí sí habrá un importe autorizado por N habitaciones y entregar N−1 exige devolución parcial. La premisa original vuelve a aplicar tal cual.

## 4. Diseño elegido

Como el socio termina en el wizard de selección de habitaciones, **no hace falta una segunda ronda de réplica**. El camino queda idéntico al pre-pago:

```
SIN_CUPO con tiposSinCupo, estando en Confirmando
 ├─ quitarHabitacionesDeTipos()      ← ya existe, sin cambios
 ├─ Confirmando → Temporal            ← lo nuevo
 ├─ habitaciones/paquetes: Activo → Temporal
 ├─ OP_ACTUALIZACION (vuelve a ser un hold, no una venta)
 └─ avisar al socio
     └─ front: velo con el aviso + botón → wizard de habitaciones
```

### 4.1 Dos requisitos derivados, no opcionales

**a) La reservación tiene que volver a `Temporal`.** El wizard entra por `agregarHabitacion` → `reservacionEditable()`, que exige `ESTADO_TEMPORAL`. En `Confirmando` el botón de agregar fallaría con 404. Sin esto, el redirect deja al socio en una pantalla donde no puede hacer nada.

**b) Hay que renovar el TTL.** `reservacionFechaHoraExpira` se fijó al crear la Temporal. Si el socio llegó al pago con 3 minutos restantes y lo devolvemos al wizard, `ReservacionesExpirarTemporales` lo cancela casi de inmediato: habría perdido todo por la vía lenta. Se repone la ventana completa (`reservacion.temporal_ttl_minutos`) porque el ajuste no fue culpa suya.

### 4.2 Lo que NO cambia

- **Veredicto sin `tiposSinCupo`** (Reservaciones V2 antiguo o sin detalle) → se sigue cancelando completo. Respaldo conservador: nunca dejar cupo comprometido que el maestro rechazó.
- La pantalla terminal actual **se conserva** para ese caso.
- `Reservaciones V2` no se toca en ningún punto de esta propuesta.

---

## 5. Cambios, línea por línea

### 5.1 `ReservacionService` — reversión `Confirmando → Temporal`

**Archivo:** `app/Services/Reservaciones/ReservacionService.php`
**Método nuevo**, justo después de `confirmar()` (~L780), porque es su inverso y se lee mejor pegado a él:

```php
public function revertirAHold(Reservacion $reservacion): bool
```

| # | Instrucción | Por qué |
|---|---|---|
| 1 | `DB::transaction(...)` + `Reservacion::where(...)->lockForUpdate()->first()` | Mismo candado que `confirmar()`. Entre el veredicto y esta escritura cabe el barrido del TTL o un veredicto duplicado |
| 2 | `if ($vigente->reservacionEstado !== ESTADO_CONFIRMANDO) return false;` | Idempotencia. El outbox reintenta veredictos; revertir dos veces no debe hacer nada |
| 3 | `$ids = ReservacionHabitacion::where('idReservacion',…)->where(estado, ESTADO_ACTIVO)->pluck(…)` | Solo las que `confirmar()` activó. Filtrar por `Activo` (no por `ESTADOS_VIGENTES`) evita tocar filas que ya estaban Temporal |
| 4 | `ReservacionHabitacionPaquete::temporalizarDeHabitaciones($ids)` | Método nuevo — §5.2 |
| 5 | `ReservacionHabitacion::temporalizarVarias($ids)` | Método nuevo — §5.2 |
| 6 | `$vigente->revertirAHold()` | Método nuevo en el modelo — §5.3 |
| 7 | `Replicacion::marcar($id, Replicacion::OP_ACTUALIZACION)` | **No** `OP_CONFIRMACION`: vuelve a ser un hold. Si viajara como confirmación, Reservaciones V2 sellaría maestría sobre una venta que no ocurrió |
| 8 | `return true` | |

> **Orden crítico:** el paso 6 va **después** del 4-5. Si la cabecera volviera a `Temporal` antes que el detalle, un `vigentesDeReservacion` concurrente vería una Temporal con habitaciones `Activo` — un estado que no existe en el modelo.

**No** se llama a `recalcularTotales()` aquí: `quitarHabitacionesDeTipos()` ya lo hace y repetirlo solo duplica trabajo.

### 5.2 Modelos de detalle — los inversos de `activar*`

**`app/Models/Modulos/Reservacion/ReservacionHabitacion.php`** (~L285, debajo de `activarVarias()`):

```php
/** Devuelve un conjunto de habitaciones a Temporal (Activo → Temporal, veredicto SIN_CUPO tras el pago). */
public static function temporalizarVarias(array $idsReservacionHabitacion): void
{
    if ($idsReservacionHabitacion === []) {
        return;
    }

    self::whereIn('idReservacionHabitacion', $idsReservacionHabitacion)
        ->where('reservacionHabitacionEstado', self::ESTADO_ACTIVO)
        ->update(['reservacionHabitacionEstado' => self::ESTADO_TEMPORAL]);
}
```

> El `->where(ESTADO_ACTIVO)` es la diferencia con `activarVarias()` (que no filtra) y con `cancelarVarias()`: **nunca debe resucitar una `Cancelado`**. Sin ese filtro, una habitación que el socio quitó a mano volvería a la vida.

**`app/Models/Modulos/Reservacion/ReservacionHabitacionPaquete.php`** (~L151): mismo patrón, espejo exacto de `activarDeHabitaciones()`:

```php
public static function temporalizarDeHabitaciones(array $idsReservacionHabitacion): void
```
con `->where('reservacionHabitacionPaqueteEstado', self::ESTADO_ACTIVO)`.

### 5.3 `Reservacion` — reversión de la cabecera

**`app/Models/Modulos/Reservacion/Reservacion.php`** (~L229, inmediatamente después de `marcarConfirmando()`, del que es el inverso):

```php
public function revertirAHold(): void
{
    $this->reservacionEstado = self::ESTADO_TEMPORAL;
    $this->reservacionFechaHoraConfirmacion = null;
    $this->reservacionMetodoPago = null;
    $this->reservacionFechaHoraExpira = self::calcularVencimiento(
        Carbon::now(),
        (int) config('reservacion.temporal_ttl_minutos', 30)
    );
    $this->save();
}
```

Cuatro columnas, cada una por una razón distinta:

| Columna | Valor | Por qué |
|---|---|---|
| `reservacionEstado` | `Temporal` | Es lo que hace que `reservacionEditable()` vuelva a aceptarla y el wizard pueda agregar habitaciones. Sin esto, el redirect deja al socio frente a un 404 |
| `reservacionFechaHoraConfirmacion` | `null` | La lee `confirmandoAtascadas()` para la alerta R1. Dejarla con valor es inocuo hoy —el filtro es por estado— pero es un dato mentiroso: dice que se confirmó a una hora en la que no se confirmó nada |
| `reservacionMetodoPago` | `null` | Se guarda *"para conciliar después"*. Un pago que no se entregó no debe dejar rastro de con qué se pagó |
| `reservacionFechaHoraExpira` | renovada desde `now()` | **El punto que más importa.** `segundosParaExpirar()` solo devuelve valor en `Temporal`, así que el cronómetro del front revive al revertir — con el vencimiento viejo, que ya pudo pasar |

`use Carbon\Carbon;` ya está importado en el modelo.

### 5.4 `ReplicacionController::resolverSinCupo()` — la decisión

**Archivo:** `app/Http/Controllers/Reservaciones/ReplicacionController.php` (~L262)

**L274-275** — la condición pasa a depender del detalle, no del estado:

```php
// ANTES
$parcial  = $reservacion->reservacionEstado === Reservacion::ESTADO_TEMPORAL
    && $tiposSinCupo !== [];

// DESPUÉS
$eraPago  = $reservacion->reservacionEstado === Reservacion::ESTADO_CONFIRMANDO;
$parcial  = $tiposSinCupo !== [];
```

**L277-286** — la rama `! $parcial` **se queda intacta**. Sigue cubriendo el veredicto sin detalle, que es el respaldo conservador.

**Después de L288** (`$resultado = $servicio->quitarHabitacionesDeTipos(...)`) se inserta la reversión:

```php
if ($eraPago) {
    $servicio->revertirAHold($reservacion->refresh());
}
```

> **Orden crítico:** primero se quitan las habitaciones, después se revierte. Al revés, `revertirAHold` devolvería a `Temporal` habitaciones que un instante más tarde se van a eliminar — trabajo perdido y una ventana con estado incoherente.
>
> El `->refresh()` es necesario: `quitarHabitacionesDeTipos()` escribió sobre otra instancia y la de aquí tiene los totales viejos.

**L293-302** — los mensajes. Hoy hay dos ramas (`vacia` / no). Con el pago de por medio hacen falta cuatro, porque el socio necesita saber que **su pago no se entregó**:

| Caso | Mensaje |
|---|---|
| Hold, quedan habitaciones | *(actual, sin cambios)* |
| Hold, vacía | *(actual, sin cambios)* |
| **Pago, quedan** | *"No alcanzó {tipos}. No se realizó ningún cargo y tus otras N habitaciones siguen apartadas: elige otra habitación o continúa al pago."* |
| **Pago, vacía** | *"No alcanzó {tipos} y tu reservación se quedó sin habitaciones. No se realizó ningún cargo; tus fechas siguen guardadas."* |

`nombresDeTipos()` (~L437) se reutiliza tal cual.

**~L465 `avisarSinCupo()`** — sin cambios de firma. En `datos` ya viajan `'evento' => 'reservacion.sincupo'` y `tiposSinCupo`; añadir `'huboPago' => $eraPago` para que el front distinga los dos velos.

### 5.5 `estadoConfirmacion` — el canal por el que el front se entera

**Archivo:** `app/Http/Controllers/Reservaciones/ReservacionController.php` (~L429-441)

Este es el punto que hace que el cambio **se vea**. Hoy el front sondea y solo mira `reservacionEstado`; con la reversión leerá `Temporal`, que **no está en su tabla de desenlaces** (`Reservado` / `Cancelado` / seguir esperando) y caería a `esperaAgotada` → *"Tu pago quedó registrado"*, que sería mentira.

En el array `data` se agrega una clave:

```php
'ajusteSinCupo' => (new AjusteSinCupoService())->paraReservacion($idReservacion),
```

Devuelve `null` o `{ tipos, mensaje, importeNuevo, totalHabitaciones }`. Se modela **calcado de `AvisoMembresiaService`** —que ya vive en ese mismo array— porque resuelve un problema idéntico: leer el último rastro que dejó el veredicto y traducirlo a algo que la pantalla pinta, sobreviviendo a un F5. Reusar ese patrón evita inventar un canal nuevo.

> **Coste:** una consulta más por sondeo. El endpoint se pide cada segundo, así que debe ir por índice de `idReservacion` — igual que el aviso de membresía, que ya paga ese coste.

### 5.6 Front — `Pago.vue` y el store

**Archivo:** `CL032FRONT/src/modulos/reservaciones/reservacion/Pago.vue`

**L249-266**, dentro de `esperarVeredicto()`. Se antepone la rama nueva a la de `Cancelado`:

```js
if (estado === 'Temporal') {
  esperando.value = false
  ajusteSinCupo.value = store.ajusteSinCupo   // mensaje redactado por el backend
  return
}
```

`store.esperarVeredicto()` ya devuelve cualquier estado distinto de `Confirmando`, así que **no hay que tocarlo**. `fetchEstadoConfirmacion` sí: añadir `if (d.ajusteSinCupo !== undefined) this.ajusteSinCupo = d.ajusteSinCupo`, igual que ya trata `membresiaAviso`.

**L662-671**, el velo terminal. Se duplica en un segundo velo con dos diferencias:

```html
<div v-if="ajusteSinCupo" class="espera-velo">
    <i class="bi bi-calendar-check espera-icono"></i>   <!-- no --alerta: no es un fallo -->
    <h2 class="espera-titulo">Ajustamos tu reservación</h2>
    <p class="espera-detalle">{{ ajusteSinCupo.mensaje }}</p>
    <button class="espera-btn" @click="onAjustarSeleccion">Elegir otra habitación</button>
</div>
```

> **El handler ya existe.** `onBack()` (~L298) hace exactamente lo pedido: lleva a `reservaciones.reservacion.socio` o `.invitado` según el tipo, **sin** `store.reset()`, así que la reservación y las fechas sobreviven. `onAjustarSeleccion` es una línea: limpiar `ajusteSinCupo` y llamar a `onBack()`.
>
> Contrasta con `onElegirOtrasFechas()` (~L277), que **sí** hace `store.reset()` — correcto para la cancelación total, incorrecto aquí.

**L127** — el `computed` que decide si el botón de pago está vivo incluye `!sinCupo.value`. Hay que sumar `!ajusteSinCupo.value` o el botón quedaría clicable detrás del velo.

---

## 6. Pruebas

En `tests/Feature/Reservaciones/DistribucionAutomaticaInvitadoTest.php`, junto a los tres casos `SIN_CUPO` que ya existen:

1. **Parcial post-pago con remanente** — confirmar, `resolverSinCupo` con un tipo, y verificar: estado `Temporal`, habitaciones restantes en `Temporal` (**no** `Activo`), `reservacionMetodoPago` null, `expiraEnSegundos` > 0, importe recalculado.
2. **Parcial post-pago que deja vacía** — la reservación sobrevive vacía, como en el hold.
3. **Sin `tiposSinCupo`** — sigue cancelando completo (protege la rama que no cambia).
4. **Idempotencia** — dos veredictos iguales; el segundo `revertirAHold` devuelve `false` y no toca nada.

## 7. Alcance

| Archivo | Cambio |
|---|---|
| `ReservacionService.php` | Método `revertirAHold()` |
| `ReservacionHabitacion.php` | `temporalizarVarias()` |
| `ReservacionHabitacionPaquete.php` | `temporalizarDeHabitaciones()` |
| `Reservacion.php` | `revertirAHold()` (cabecera) |
| `ReplicacionController.php` | Condición de parcialidad + reversión + 2 mensajes nuevos + `huboPago` |
| `ReservacionController.php` | `ajusteSinCupo` en `estadoConfirmacion` |
| `AjusteSinCupoService.php` | **Nuevo**, calcado de `AvisoMembresiaService` |
| `CL032FRONT/…/Pago.vue` | Rama `Temporal`, velo nuevo, `onAjustarSeleccion`, `bloqueado` |
| `CL032FRONT/…/reservacionStore.js` | Estado `ajusteSinCupo` + lectura en `fetchEstadoConfirmacion` |
| `DistribucionAutomaticaInvitadoTest.php` | 4 casos nuevos |

**Orden sugerido de implementación:** los modelos primero (§5.2, §5.3, aislados y verificables con la suite actual), luego el servicio (§5.1), después el controlador (§5.4) y por último el canal al front (§5.5, §5.6).

## 8. Riesgo residual

Reservaciones V2 conserva su hold en `Temporal` mientras todo esto ocurre, así que las habitaciones que sí cabían **no se sueltan en ningún momento** — ni siquiera durante la ventana entre el veredicto y la reversión. Eso es lo que hace seguro el cambio.

Lo que sí cambia de naturaleza: **hoy una reservación en `Confirmando` es un estado terminal** desde el punto de vista del portal (solo el veredicto la mueve). Con esto deja de serlo. Cualquier código futuro que asuma *"Confirmando solo avanza"* tendrá que contemplar el retroceso — por eso el candado del paso 1 y el `return false` del paso 2 de §5.1 no son opcionales.

---

## Anexo — decisiones descartadas

**❌ Que Reservaciones V2 acepte parcialmente** (escribir el snapshot solo con lo que cabe y responder `OK` + `tiposSinCupo`). Evitaría la segunda ronda, pero: el privado decidiría qué habitación pierde el socio, `replicarSnapshot()` borra y reinserta el detalle completo, y sellaría maestría sobre un conjunto que el portal nunca eligió. Cambia la semántica del maestro para ahorrar un viaje. No compensa.

**❌ Mantener la reservación en `Confirmando` y lanzar una segunda ronda de réplica.** Fue el diseño inicial de esta propuesta. Se descartó al decidir que el socio vuelve al wizard: si va a elegir otra habitación, la reservación tiene que ser editable, y eso exige `Temporal` de todos modos. La segunda ronda sería trabajo extra sin destinatario.

**❌ Preguntar al socio antes de ajustar** (*"¿continuamos con 2 habitaciones por $X o cancelamos?"*). Más respetuoso, pero deja la reservación en `Confirmando` esperando a una persona: si abandona la pestaña, el cupo queda retenido sin dueño hasta que algo lo limpie. Se prefirió el ajuste automático porque hoy no hay cargo y el socio no pierde nada frente a la alternativa actual, que es perderlo todo.
