# Baja automática de socios por padrón de membresía

**Fecha:** 2026-08-14 · **Proyecto:** CL001 Pádel (PADELBACK) + Portal Público
**Estado:** implementado y verificado en local. Falta correr el SQL y subir.

---

## El problema

`wd_membresia.membresiaBeneficiarios` es un TEXT con un JSON: el padrón de
beneficiarios de cada membresía. **Lo escribe el portal público**
(`CL001_Asturiano/PORTALPUBLICO`) sobre esta misma base de datos.

`pad_registropadel` guarda un socio por beneficiario, enlazado con
`idMembresia` + `registroPadelBeneficiarioContador`. Nadie volvía a mirar ese
padrón después del registro: si al beneficiario lo daban de baja allá, su cuenta
de pádel seguía entrando, reservando y jugando.

Y hay una segunda cara, que es la que obliga a resolverlo bien: **el socio dado
de baja que nunca vuelve a abrir la app**. Los 26 lugares del sistema que listan
socios filtran por `registroPadelEstado = 'Activo'`. Mientras esa columna no
cambie, ese socio se sigue pudiendo seleccionar, invitar, meter a un grupo y
contar como jugador confirmado — ocupando cupo en partidas a las que no va a ir.
No es que queden filas viejas: es que se sigue generando dato nuevo.

---

## Quién hace qué

| Pieza | Qué hace | Cuándo |
|---|---|---|
| **Portal público** | detecta el cambio, mueve `registroPadelEstado`, escribe la bitácora | al recibir el padrón nuevo, al instante |
| **Login y middleware de pádel** | lo dejan fuera y le borran los tokens | su siguiente intento |
| **`padel:limpiar-bajas-padron`** | lo saca de sus partidas por venir | cada 5 minutos |

Lo urgente —que no entre y que no le aparezca a nadie— pasa **en el mismo momento
en que llega el padrón**, sin esperar a ningún reloj. El schedule sólo limpia lo
que queda, y por eso puede ir a su ritmo.

### Por qué la detección vive en el portal público

Porque es el único que sabe **cuándo** cambia el padrón: él lo escribe. Cualquier
otra opción es preguntar a ciegas cada tanto.

Las alternativas que se descartaron, y por qué:

- **Un barrido que recorra a todos los socios cada pocos minutos.** Funciona,
  pero pregunta por miles de socios para enterarse de un cambio que otro sistema
  ya conocía. Y la latencia nunca baja de la frecuencia del barrido.
- **Leer el padrón en el middleware, en cada petición.** Es una consulta a
  `wd_membresia` por cada request de cada socio — y esa tabla trae un `longblob`
  (`membresiaConstanciaFoto`, hasta 228 KB), así que no es una consulta barata.
  Todo para enterarse de algo que ya está en la fila que se acaba de cargar.
- **Un estado nuevo en `registroPadelEstado`** del tipo "por revisar". Serían dos
  preguntas en una columna: *¿puede usar su cuenta?* y *¿hay que revisarlo?* —
  y un socio puede ser sí en las dos a la vez. Además obligaría a que los 26
  lugares que filtran por estado aprendieran el valor nuevo, y el que se olvidara
  haría lo incorrecto en silencio.
- **Una tabla-buzón** donde el portal dejara recados. Sobra: el pendiente se
  deduce del propio dato (`RegistroPadel::conRetiroPendiente`), sin que nadie
  tenga que avisar.

### Por qué el retiro de partidas se queda en pádel

Porque el portal público no sabe de partidas, invitaciones ni motivos de cierre.
Su trabajo termina en el estado. El schedule lo recoge de ahí y reutiliza
`PartidaService::retirarDePartidas()`, el mismo que ya usan la sanción y la
desactivación manual — cambiando sólo el motivo de cierre a `'Dado Baja'`. Va
**sin deporte**: la baja es de la cuenta completa, no de una disciplina.

---

## Lo que hace el portal público, en orden

`App\library\FSG\PadronPadel::sincronizar()`, llamado desde `editarMembresia()`
justo después del `save()`.

### 1. Reconciliar contador y nombre

**El contador de un beneficiario puede cambiar sin que cambie la persona.** Si
sólo se comparara por contador, esa persona parecería haber desaparecido del
padrón y se le daría de baja estando perfectamente bien.

Por eso, a quien no aparece por contador se le busca por nombre antes de darlo
por ido:

| Situación | Qué pasa |
|---|---|
| Su contador **sí** está en la lista | Es él. Se le actualiza el nombre si cambió |
| Su contador **no** está, pero **su nombre sí** | Es él con contador nuevo. Se actualiza el contador |
| Ni contador ni nombre | Ahora sí, `'Dado Baja'` |

### 2. Ajustar estados

- `'Activo'` que ya no está en el padrón → `'Dado Baja'`
- `'Dado Baja'` que reaparece → `'Activo'`

**El orden importa.** Si las bajas fueran primero, se daría de baja a quien
únicamente cambió de contador y ya no habría a quién reconciliar.

### Lo que nunca se toca

- **`registroPadelUsername`.** Se armó con el nombre al registrarse, pero es con
  lo que el socio entra a la app. Si le cambia el nombre se actualiza
  `registroPadelBeneficiarioNombreCompleto` y nada más.
- **`'Inactivo'` y `'Bloqueado'`.** Son decisiones de un administrador de pádel o
  del sistema por intentos fallidos. El padrón no las revierte.

> `'Dado Baja'` lo escribe **únicamente** el portal público. Ningún flujo de
> pádel lo pone. De eso depende que se pueda reactivar solo: un socio en ese
> estado salió por el padrón, nunca por una decisión administrativa.

---

## Las tres guardas

**1. Lista ilegible ≠ lista vacía.**

Que no venga la llave en el envío, o que traiga algo que no es JSON, **no**
significa que la membresía se quedó sin beneficiarios: significa que no llegó el
dato. Ahí no se toca nada. Una lista válida y vacía (`[]`) sí es una respuesta
—esa membresía ya no tiene a nadie— y ahí sí se da de baja.

No es hipotético: `procesarMembresias()` arma el valor con
`$membresia['membresiaBeneficiarios'] ?? null`, así que un envío sin esa llave
llega como `null`.

**2. Nombre repetido → no se toca a nadie.**

Si el nombre de un socio coincide con más de un beneficiario del padrón, queda
anotado en el log y se deja como está, para que lo mire una persona.

Esa guarda es también lo que permite normalizar de forma agresiva —quitando
acentos, colapsando espacios, todo a mayúsculas— sin riesgo: si de tanto
normalizar dos beneficiarios distintos acaban con el mismo nombre, no se toca a
nadie. Y sin esa normalización, `"MARÍA"` contra `"MARIA"` bastaría para dar de
baja a una socia válida.

**3. No se le roba el contador a otro socio.**

Antes de escribir un contador nuevo se comprueba que ningún otro socio de esa
membresía lo tenga. Y el índice único `uq_registropadel_beneficiario` lo sostiene
desde la base.

El criterio detrás de las tres es el mismo: **sale mucho más caro dar de baja a
un socio válido —pierde sus partidas y no hay forma de devolvérselas— que dejar
entrar un día de más a quien ya no debería.**

---

## Archivos

### Portal público (`CL001_Asturiano/PORTALPUBLICO`)

| Archivo | Cambio |
|---|---|
| `app/library/FSG/PadronPadel.php` | **nuevo** — toda la lógica |
| `app/Models/api/Membresia.php` | una llamada en `editarMembresia()`, después del `save()` |

No se toca `nuevaMembresia()`: una membresía recién creada todavía no tiene
cuentas de pádel colgadas.

Todo va dentro de `try/catch`. Un problema aquí no debe tumbar el alta ni la
actualización de la membresía, que es a lo que vino esa llamada.

### Pádel (`CL001_PADELBACK`)

| Archivo | Cambio |
|---|---|
| `app/Console/Commands/LimpiarBajasPadron.php` | **nuevo** — el schedule |
| `app/Models/Modulos/Padel/RegistroPadel.php` | + `conRetiroPendiente()` |
| `app/Models/Modulos/Padel/Partida.php` | + `porVenirConReservaViva()`, que ahora comparten dos consultas |
| `app/Services/Administracion/SocioService.php` | `retirarPorBajaDePadron()` reemplaza a `sigueVigenteEnMembresia()` y `darDeBajaPorMembresia()` |
| `app/Http/Middleware/SocioActivo.php` | ya no lee el padrón; mensaje propio para `'Dado Baja'` |
| `app/Services/DeportesCam/AuthService.php` | igual |
| `app/Models/Modulos/Padel/Membresia.php` | se fue `tieneBeneficiario()` |
| `routes/console.php` | alta del schedule |
| `docs/migration_baja_membresia.sql` | **nuevo** — acción 94 y los dos índices |

**Pádel ya no lee el padrón en ningún punto del camino de entrada.**
`Membresia::beneficiarios()` se queda, pero sólo para el registro, que es donde
nació.

---

## Verificado

**Lado portal público** — 24 comprobaciones sobre la lógica de decisión, más los
9 socios y sus padrones reales:

- las cinco formas de lista ilegible (`null`, vacía, espacios, texto plano, JSON
  inválido) → no se toca nada;
- lista válida vacía → sí da de baja;
- cambio de nombre → se actualiza el nombre, no el estado;
- **cambio de contador → se actualiza el contador, no se da de baja**;
- acentos, mayúsculas y espacios de más → empatan, no dan de baja;
- nombre repetido → nadie se toca y queda anotado;
- no se le quita el contador a otro socio;
- reaparece → vuelve a `'Activo'`; un `'Inactivo'` o `'Bloqueado'` no se toca en
  ninguna dirección;
- **contra los 9 socios reales: cero bajas, cero cambios.**

**Lado pádel** — 11 comprobaciones de punta a punta, en transacción con
`rollBack()`:

- estando `'Activo'` el schedule no lo ve;
- puesto en `'Dado Baja'`, aparece como pendiente y sus partidas siguen intactas;
- el login lo rechaza con el mensaje de membresía, no con el genérico;
- el schedule lo saca de sus partidas por venir y deja de aparecer;
- **el historial no se reescribe**: de 5 renglones activos se retiró sólo la
  partida futura;
- el cierre queda con motivo `'Dado Baja'`;
- una segunda pasada no hace nada;
- reactivado, vuelve a poder entrar.

---

## Para subir

**1. El SQL** (`docs/migration_baja_membresia.sql`) — acción 94 de bitácora y los
dos índices. Es re-ejecutable: corre igual en local, staging y producción, y
correrlo dos veces no hace daño. Trae arriba la consulta de duplicados que hay
que revisar antes del índice único.

**2. Pádel.** Puede ir solo: sin el portal público nadie queda dado de baja
todavía, así que no se rompe nada — se queda como estaba.

**3. Portal público.** Ahí arranca la detección.

---

## Pendiente

**La reactivación no deja rastro en bitácora.** Sólo la baja lo hace. Es la
dirección benigna —el socio vuelve a entrar y nadie llama a reclamar—, pero si
más adelante se quiere el historial completo, es un `INSERT` más en
`PadronPadel::aplicar()` con su acción nueva.

**Las partidas de las que salió no se recuperan al reactivarlo**, igual que al
levantar una sanción. Es el criterio que ya sigue `SocioService::reactivar()`.
