# 05 · Contrato de API

Los cuatro endpoints, el payload del canal inverso y **cómo interpreta Reservaciones V2 cada respuesta**.
Esa última parte es la que decide si el canal se autorrepara o se apaga solo.

---

## 5.1 Los cuatro endpoints

| Método | Ruta | Canal | Firma | Estado |
|---|---|---|---|---|
| `GET` | `/reservaciones/replicacion/pendientes` | Ida | `path` | 🟢 Activo |
| `POST` | `/reservaciones/replicacion/veredicto` | Ida | `path` | 🟢 Activo |
| `POST` | `/reservaciones/replicacion/entrante` | **Inverso** | **`cuerpo`** | 🔴 No registrada |
| `GET` | `/reservaciones/replicacion/estado` | **Inverso** | `path` | 🔴 No registrada |

Se montan en la **raíz**, sin prefijo `/api` ni middleware `web`, porque la firma HMAC usa el
path exacto y no debe pasar por sesión ni CSRF.

---

## 5.2 `POST /entrante` — el payload

**Una reservación por petición**, no lote: el outbox de Reservaciones V2 ya da el paralelismo, y un lote
mezclaría veredictos parciales.

```jsonc
{
  "idReservacionPrivado":   163290,          // PK en Reservaciones V2. El emparejamiento es por AQUÍ
  "replicaVersion":         7,               // rsv_replicacionsalida.replicacionSalidaVersion
  "reservacionHashReplica": "<sha256 del snapshot>",

  "reservacion": {
    "reservacionEstado":         "Reservado", // legible, ya traducido del char por el trait
    "reservacionTipo":           "Socio",
    "membresiaNo":               "1234",      // CLAVE NATURAL cross-servidor
    "reservacionNo":             48213,       // folio del club
    "reservacionFechaLlegada":   "2026-12-24",
    "reservacionFechaSalida":    "2026-12-27",
    "reservacionImporte":        12650.50,
    "reservacionNoHabitaciones": 3,
    "reservacionNotas":          null,
    "reservacionRecibo":         null,        // ⚠ se valida pero NO se aplica (decisión D5)
    "beneficiarioNombreCompleto": "..."       // display; el portal no tiene beneficiarios
  },

  "habitaciones": [
    {
      "idReservacionHabitacionPrivado":    190041,   // ← EL PUNTERO
      "idTipoHabitacion":                  4,        // catálogo espejo, mismos PK
      "reservacionHabitacionEstado":       "Activo",
      "reservacionHabitacionTipo":         "Socio",
      "reservacionHabitacionFechaLlegada": "2026-12-24 15:00:00",
      "reservacionHabitacionFechaSalida":  "2026-12-27 12:00:00",
      "reservacionHabitacionNoAdultos":    2,
      "reservacionHabitacionNoMenores":    1,
      "reservacionHabitacionCantidad":     1,
      "reservacionHabitacionImporte":      8400.00,
      "reservacionHabitacionNotas":        null,

      "paquetes": [
        { "idReservacionHabitacionPaquetePrivado": 184997, "idPaquete": 12,
          "fechaLlegada": "2026-12-24 15:00:00", "fechaSalida": "2026-12-27 12:00:00",
          "importeHabitacionPaquete": 8400.00,
          "reservacionHabitacionPaqueteEstado": "Activo" }
      ],
      "extras": [
        { "idReservacionHabitacionExtraPrivado": 13996, "idExtra": 7,
          "reservacionHabitacionExtraCantidad": 2, "reservacionHabitacionExtraImporte": 250.50,
          "reservacionHabitacionExtraFechaInicio": "2026-12-24",
          "reservacionHabitacionExtraFechaFin":    "2026-12-26",
          "reservacionHabitacionExtraNoDias":      3,
          "reservacionHabitacionExtraTipoPersona": "Adulto" }
      ]
    }
  ]
}
```

### Campos que Reservaciones V2 puede enviar y el portal ignora

| Campo | Por qué |
|---|---|
| `habitaciones.*.idPaquete` | La columna se eliminó del portal. El paquete es de un **tramo**, y viaja en `paquetes[]`. Ver [02 §2.6](02-ESQUEMA-BASE-DE-DATOS.md#26-una-columna-que-se-eliminó-y-no-vuelve) |
| `reservacion.reservacionRecibo` | **Decisión D5 pendiente**: falta decidir si el socio ve las reservaciones convertidas a recibo |
| `reservacion.reservacionEstadoTransferencia` | Idem D5 |
| `reservacion.idBeneficiario` | El portal no tiene tabla de beneficiarios |

No se declaran en el FormRequest, así que `validated()` los descarta y nadie puede escribirlos
por accidente.

---

## 5.3 Estrictez del contrato: no es uniforme, y es deliberado

| Estricto | Tolerante |
|---|---|
| `idReservacionPrivado`, `replicaVersion`, `reservacionHashReplica` | `reservacionNotas` |
| `membresiaNo`, estados, tipos, fechas | `beneficiarioNombreCompleto` |
| Punteros del detalle, `idTipoHabitacion`, `idExtra`, importes | `reservacionRecibo` |

**Un error en la columna izquierda escribe la reservación de un socio sobre la de otro.**
Rechazar un snapshot entero por lo de la derecha deja al socio sin ver su reservación por un
dato que no cambia ninguna decisión.

Un detalle que parece menor y no lo es:

```php
'habitaciones' => 'present|array',   // `present`, NO `required`
```

Un arreglo **vacío es legítimo** —una reservación cancelada no tiene habitaciones vigentes—
pero la clave debe venir. Si faltara, no se podría distinguir "sin habitaciones" de "el maestro
no las mandó", y la reconciliación cancelaría todas las locales por un payload truncado.

---

## 5.4 Orden de los cortes

**Cada uno va antes de cualquier escritura, y el orden no es intercambiable.**

```
1. Membresía        → sin destino no hay nada que escribir     422 membresia_no_localizada
2. Localizar        → por puntero; si no existe, es alta
3. Maestría         → si sigue siendo del portal, hay carrera  409 maestria_portal
4. Versión          → ya se aplicó una igual o más nueva       409 version_obsoleta
5. Hash             → idéntico al último aplicado              200 sin_cambios
6. Catálogos        → un id sin espejo local                   422 catalogo_desconocido
7. Transacción      → cabecera + detalle + sello, todo o nada
8. Avisos           → FUERA de la transacción, best-effort
```

**Versión antes que hash**: una versión obsoleta con el mismo hash sigue siendo una respuesta
incorrecta si se cierra como "aplicada".

**El sello va al final de la transacción**, no al principio: si algo revienta a media
reconciliación, la versión no avanza y el snapshot vuelve completo en el ciclo siguiente.
Sellar antes dejaría una reservación con medio detalle y la versión ya aplicada — y ese
snapshot no volvería nunca.

---

## 5.5 Respuestas, y cómo las interpreta Reservaciones V2

**Reservaciones V2 ramifica por el CÓDIGO HTTP, no por el texto.** `5xx` = transitorio (reintenta y
alimenta el cortacircuitos); `4xx` = permanente (descarta la marca y alerta).

| Código | `motivo` | Significado | Acción de Reservaciones V2 |
|---|---|---|---|
| `200` | — | Aplicada. Devuelve `idReservacionPortal` | Cierra la marca (si la versión no subió) |
| `200` | `sin_cambios` | El hash coincide con el último aplicado | Cierra la marca |
| `409` | `version_obsoleta` | Llegó una versión ≤ la aplicada | Cierra: hay una más nueva en camino |
| `409` | `maestria_portal` | La reservación local sigue siendo del portal | **Reintenta**: carrera con el veredicto |
| `422` | `membresia_no_localizada` | No existe en `wd_membresia` | Descarta + alerta `R4` |
| `422` | `catalogo_desconocido` | `idTipoHabitacion`/`idPaquete`/`idExtra` sin espejo | Descarta + alerta `R9` |
| `422` | `contrato` | Falla de validación del payload | Descarta + alerta |
| `5xx` | `interno` | Fallo inesperado del portal | Reintenta con backoff |

### ⚠️ Nunca devolver `500` ante un error de datos

Reservaciones V2 lo clasifica como transitorio, reintenta cinco veces y **abre el cortacircuitos**,
deteniendo las réplicas sanas de **todas las demás reservaciones**. Un dato malo de un socio
no puede parar el canal de los otros.

### Por qué `maestria_portal` es `409` y no `422`

Es una **carrera**, no un error: el veredicto que sella la reservación todavía no se procesó.
Reservaciones V2 debe **reintentar**. Un `4xx` de descarte perdería el cambio para siempre.

---

## 5.6 `GET /estado?ids=163290,163291`

Solo lectura, para el reconciliador de Reservaciones V2. Devuelve lo que el portal tiene **aplicado**:

```jsonc
{
  "success": true, "code": 200,
  "data": { "items": [
    { "idReservacionPrivado": 163290, "idReservacionPortal": 82,
      "reservacionEstado": "Reservado", "reservacionMaestro": "Privado",
      "reservacionVersionReplica": 7,
      "reservacionHashReplica": "ca2909…", "reservacionFechaReplica": "2026-08-18 16:58:09" }
  ]}
}
```

**Tope de 200 ids por petición.** Sin él, una llamada con 50k ids traería la tabla entera a
memoria y el endpoint de vigilancia sería el problema.

> **Este endpoint no es opcional.** Desde que Reservaciones V2 captura con observadores de Eloquent más un
> trigger de cabecera (su `DECISION_CAPTURA_FASE3.md`), los `update()` masivos por query
> builder no disparan eventos de modelo. El reconciliador es la tercera capa que los caza, y
> este endpoint es su ojo.

---

## 5.7 Canal de ida — referencia

**`GET /pendientes?wait=25`** — long-poll. Si hay marcas `Pendiente`, responde los snapshots y
las pasa a `Procesando` (lease). Si no, sostiene la conexión hasta `wait` segundos.

**`POST /veredicto`**:

```jsonc
{
  "idReplicacion": 812, "idReservacion": 4471, "estado": "OK",
  "idReservacionRemoto": 163290,     // → idReservacionPrivado
  "reservacionNoRemoto": 48213,      // → reservacionNoPrivado
  "mensaje": "Replicado",
  "tiposSinCupo": [4, 7],            // solo con SIN_CUPO
  "mapeo": {                         // ⚠ solo se aplica con el canal inverso ENCENDIDO
    "habitaciones": [
      { "portal": 9012, "privado": 190041,
        "paquetes": [{ "portal": 5511, "privado": 184997 }],
        "extras":   [{ "portal": 3301, "privado": 13996 }] }
    ]
  }
}
```

`estado` ∈ `OK` | `ERROR` | `SIN_CUPO`. Todos los campos del canal inverso son **opcionales**:
un Reservaciones V2 que no los envíe funciona exactamente igual.

> **Reservaciones V2 envía el `mapeo` hoy**, con el canal inverso apagado. El portal lo ignora — ver
> [03 §3.2](03-CONFIGURACION-Y-ENV.md). No hay que cambiar nada en Reservaciones V2 para desactivar.

---

**Anterior:** [04 · Seguridad](04-SEGURIDAD-Y-FIRMA.md) ·
**Siguiente:** [06 · Componentes de código](06-COMPONENTES-DE-CODIGO.md)
