> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rocky.global/llms.txt
> Use this file to discover all available pages before exploring further.

# CLUB PORTAL

# Portal del club (Administrador de Club)

> Nota: este documento es la referencia **técnica** del portal del club. Para la guía de uso ver la sección **Club** ([Introducción](/roles/club/introduccion)) y, para el organizador, [Gestionar clubes](/roles/organizador/clubes/gestion-clubes) y [Aprobar transferencias](/roles/organizador/clubes/aprobar-transferencias).

El portal del club (`/dashboard-club`) permite que un administrador de club inscriba a varios deportistas de una vez en los eventos de **una organización** y pague todo en **un único cobro**, con tarjeta (Redsys/Stripe) o por **transferencia bancaria** aprobada manualmente por el organizador.

Todo se apoya en piezas que ya existían y no las duplica: el motor de precios y descuentos del checkout, `payment.start` para las pasarelas y `OrderPaymentFinalizer` para dar por pagada una orden.

## 1. Actores, roles y permisos

| Actor                      | Rol / permiso                                      | Alcance                                         |
| -------------------------- | -------------------------------------------------- | ----------------------------------------------- |
| Administrador de Club      | Rol **Club** + fila activa en `club_user`          | Solo los clubes que administra                  |
| Organizador                | `organizer.clubs.view` / `organizer.clubs.edit`    | Clubes ligados a los eventos de su organización |
| Organizador (aprobaciones) | `organizer.orders_and_participants.view` / `.edit` | Ver / guardar y aprobar transferencias          |
| Personal de Rocky Global   | `administrar-organizaciones`                       | Carga los datos bancarios de una organización   |

Permisos del rol Club (migración `2026_09_10_000002`): `club.orders.view` (consultar), `club.orders.edit` (operar: crear, importar, pagar, cancelar, editar) y `club.users.manage` (administradores). Un rol personalizado puede recibir un subconjunto desde `/admin/roles`.

Middleware:

| Middleware                                       | Qué garantiza                                                                                                                                                                                                |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `check.club.role` (`CheckClubRole`)              | Rol Club **y**, si la URL lleva `{club}`, que el usuario sea administrador activo de ese club (`User::belongsToClub`). Fija el contexto de permisos de Spatie a `null` (los permisos `club.*` son globales). |
| `check.club.order.owner` (`CheckClubOrderOwner`) | Que la `{order}` de la URL tenga `club_id` y pertenezca a un club del usuario.                                                                                                                               |
| `can:club.*`                                     | El permiso concreto de cada grupo de rutas.                                                                                                                                                                  |

Además, `ClubOrderController::assertEventInClubOrganization()` impide inscribir en un evento de otra organización (404) cuando el club tiene `organizador_id`. Los clubes heredados con `organizador_id` nulo no se restringen hasta que se les asigne organización.

## 2. Modelo de datos

| Tabla / columna                                                      | Uso                                                                                                                                                                          |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clubs` (`name`, `email`, `country`, `organizador_id`, soft deletes) | El club. `organizador_id` es la organización que lo gestiona (nulo en clubes heredados).                                                                                     |
| `club_user` (`club_id`, `user_id`, `status`, `invited_by`)           | Administradores. `status`: `invited` \| `active` \| `revoked`. Único por `(club_id, user_id)`.                                                                               |
| `orders.club_id`, `orders.origin`                                    | `origin = 'club_portal'` marca una orden del portal; `club_id` la liga al club.                                                                                              |
| `eventos_x_usuarios.club_id`                                         | Inscripciones creadas por el club (también se rellena en el checkout público).                                                                                               |
| `organizadores.banco`, `numero_cuenta`, `id_cuenta`, `correo`        | Datos que se muestran al club para transferir. Migración `2026_09_15_000001` amplía `numero_cuenta` e `id_cuenta` de `BIGINT` a `VARCHAR(64)` (solo MySQL; no-op en SQLite). |

`orders.transaction` (JSON) guarda, entre otras, estas claves del flujo de club:

| Clave                            | Contenido                                                                                                                                                                                                                                                       |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payment_method`                 | `"Transferencia bancaria"` al aprobar una transferencia (lo escribe `markApproved`).                                                                                                                                                                            |
| `club_transfer_review`           | Borrador guardado con **Guardar**: `notes`, `payment_reference`, `attachment`, `saved_by`, `saved_by_name`, `saved_at`.                                                                                                                                         |
| `manual_club_transfer_approval`  | Aprobación final: `approved_by` (id), `approved_by_name` (nombre y apellidos), `at`, `notes`, `payment_reference`, `attachment` y, **solo si se aprobó superando un límite**, `capacity_override` (lista de `{ scope, name, limit, confirmed, needed, over }`). |
| `manual_failed_payment_approval` | La escribe "Aprobar pago fallido": `approved_at`, `approved_by_user_id` y, si se aprobó superando un límite, `capacity_override` (misma forma).                                                                                                                 |
| `cancelled_by_club`              | `at`, `user_id`, `previous_status` (cancelación desde el portal).                                                                                                                                                                                               |
| `expired_as_abandoned`           | Solo en órdenes **antiguas**: las canceló el proceso nocturno cuando aún alcanzaba a las órdenes del club (sección 9). Hoy ninguna orden del club recibe esta marca.                                                                                            |
| `_discount_usage_counted`        | Marca de que el uso del descuento ya se contó (idempotencia).                                                                                                                                                                                                   |

`attachment` sigue la misma forma que los adjuntos de formulario del checkout: `{ path, disk, original_name, mime_type, size }`.

## 3. Rutas

### Portal del club (`/dashboard-club`, `auth` + `check.club.role`)

| Nombre                                   | Método y URL                                                               | Permiso                    |
| ---------------------------------------- | -------------------------------------------------------------------------- | -------------------------- |
| `club.dashboard`                         | GET `/`                                                                    | (rol Club)                 |
| `club.users.index` / `invite` / `revoke` | GET/POST `/{club}/usuarios`, DELETE `/{club}/usuarios/{user}`              | `club.users.manage`        |
| `club.dashboard.events`                  | GET `/{club}/eventos`                                                      | `club.orders.edit`         |
| `club.order.create`                      | GET `/{club}/eventos/{evento}/orden/nueva`                                 | `club.orders.edit`         |
| `club.order.fields`                      | GET `.../orden/campos`                                                     | `club.orders.edit`         |
| `club.order.template`                    | GET `.../orden/plantilla`                                                  | `club.orders.edit`         |
| `club.order.import` / `import.confirm`   | POST `.../orden/importar`, `.../importar/confirmar`                        | `club.orders.edit`         |
| `club.order.preview`                     | POST `.../orden/preview`                                                   | `club.orders.edit`         |
| `club.order.store`                       | POST `.../orden`                                                           | `club.orders.edit`         |
| `club.order.participant.edit` / `update` | GET / PUT `/{club}/ordenes/{order}/participantes/{participation}[/editar]` | `club.orders.edit` + owner |
| `club.order.pay`                         | POST `/{club}/ordenes/{order}/pagar`                                       | `club.orders.edit` + owner |
| `club.order.cancel`                      | POST `/{club}/ordenes/{order}/cancelar`                                    | `club.orders.edit` + owner |
| `club.order.index`                       | GET `/{club}/ordenes`                                                      | `club.orders.view`         |
| `club.order.show` / `export` / `receipt` | GET `/{club}/ordenes/{order}[/exportar\|/comprobante]`                     | `club.orders.view` + owner |

### Organizador

| Nombre                                         | Método y URL                                                                                    | Permiso                          |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------- |
| `organizer.clubs.*`                            | `/organizer/clubes` (index, store, update, destroy, `admins`, `admins.invite`, `admins.revoke`) | `organizer.clubs.view` / `.edit` |
| `events.finances.order.detail`                 | GET `/events/{id}/finances/orders/{orderId}`                                                    | `orders_and_participants.view`   |
| `events.finances.order.saveClubTransferInfo`   | POST `.../orders/{orderId}/save-club-transfer-info`                                             | `orders_and_participants.edit`   |
| `events.finances.order.approveClubTransfer`    | POST `.../orders/{orderId}/approve-club-transfer`                                               | `orders_and_participants.edit`   |
| `events.finances.order.transferReceipt`        | GET `.../orders/{orderId}/transfer-receipt`                                                     | `orders_and_participants.view`   |
| `events.finances.order.exportClubParticipants` | GET `.../orders/{orderId}/export-club-participants`                                             | `orders_and_participants.view`   |
| `events.finances.order.apiClubParticipants`    | GET `.../orders/{orderId}/club-participants` (JSON paginado)                                    | `orders_and_participants.view`   |

## 4. Ciclo de vida de una orden de club

```mermaid theme={null}
stateDiagram-v2
    [*] --> OK: total 0 (sin cobro)
    [*] --> PENDING: tarjeta
    [*] --> PENDING_TRANSFER: transferencia
    PENDING --> APPROVED: notificación firmada de la pasarela
    PENDING --> FAILED: rechazo / cancelación en pasarela
    FAILED --> PENDING: pay() reintenta
    PENDING --> CANCELLED: cancel() del club
    FAILED --> CANCELLED: cancel() del club
    PENDING_TRANSFER --> APPROVED: el organizador aprueba
    PENDING_TRANSFER --> CANCELLED: cancel() del club
    APPROVED --> PARTIALLY_CANCELLED: reembolso parcial
    APPROVED --> CANCELADO: reembolso total
```

| Estado de la orden | Estado de las inscripciones  | Notas                                                                                                |
| ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------- |
| `OK`               | `OK`                         | Orden gratuita: cuenta el uso del descuento al crearla.                                              |
| `PENDING`          | `PENDING`                    | Espera la pasarela. Reintentable (`RECOVERABLE_ORDER_STATES`).                                       |
| `PENDING_TRANSFER` | `PENDING`                    | Espera al organizador. **No** reintentable por pasarela.                                             |
| `FAILED`           | `PENDING`                    | Reintentable.                                                                                        |
| `APPROVED`         | `OK`                         | Pago confirmado o transferencia aprobada.                                                            |
| `CANCELLED`        | `CANCELADO` (las pendientes) | Cancelación del club. Una orden del club **nunca** se cancela por expiración automática (sección 7). |

Las inscripciones de una orden en revisión por transferencia **siguen en `PENDING`** a propósito: los controles de cupo, los dashboards y las exportaciones ya entienden ese valor como "no inscrito todavía", y introducir un estado nuevo por inscripción obligaría a auditar todos los lugares donde se lee `EventoXUsuario::status`. La distinción "cómo se va a pagar" vive solo en `orders.status`.

`ClubOrderController` mantiene dos conjuntos de estados: `RECOVERABLE_ORDER_STATES = [PENDING, FAILED]` (lo que `pay()` puede reintentar) y `UNRESOLVED_ORDER_STATES = [PENDING, FAILED, PENDING_TRANSFER]` (lo que se lista en "Por pagar" y se puede cancelar).

## 5. Creación de la orden

1. `validateAndPrice()` → `CheckoutController::validateAndPriceClubGroupOrder()` valida los deportistas contra los campos del evento y calcula el precio **siempre en el servidor** (tarifa vigente, complementos con precio por deportista, descuentos, tarifa de servicio, impuestos).
2. Una petición que trae claves monetarias (`total`, `amount`, `pricing`, `discount_id`…, lista `FORBIDDEN_PRICE_KEYS`) se **rechaza entera**.
3. `GroupOrderCreationService::createFromClubPortal()` abre una transacción y:
   * vuelve a comprobar el precio con `assertPricingIntegrity()` (las partes deben sumar el total y coincidir con el número de deportistas);
   * **re-valida cupos** (evento y tarifa, contando solo inscripciones `OK`) y el descuento bajo `lockForUpdate`;
   * crea la orden con `Order::createWithSequence()` (`origin = 'club_portal'`, `club_id`), un `EventoXUsuario` por deportista (usuario existente reutilizado y con el perfil actualizado, o nuevo con rol Deportista), `is_captain` y localizadores por equipo, y los `OrderItem`.
4. El estado inicial sale de `requires_card_payment` y de `$payByTransfer` (ver sección 8).
5. Si la orden es **gratuita** (`OK`), `persistClubOrder()` despacha, ya fuera de la transacción, **un correo de confirmación por deportista** (`OrderPaymentFinalizer::dispatchParticipantConfirmations()`): no habrá un pago posterior que los envíe.

La carga por Excel (`ClubOrderExcelParser`, máximo `MAX_ROWS = 300` filas, `.xlsx`/`.xls` de hasta 5 MB) valida el archivo **entero** antes de aceptar nada, guarda las filas en la **sesión** (`club_order_import:{club}:{evento}`, nunca en el HTML) y llega a la misma `persistClubOrder()` que el formulario.

## 6. Motor de descuentos (`App\Support\ClubOrderDiscount`)

Paridad con el checkout público (`buildCheckoutDiscountSequence` / `evaluateDiscountApplication`):

* **Código manual** (`DISCOUNT_PER_CODE`): sin distinguir mayúsculas; valida evento, activo, aplicabilidad a la tarifa, cupo y ventana de fechas.
* **Automático por turno** (`DISCOUNT_PER_TURN`): sin código, mientras `in_use < quantity`; sin error si no aplica.
* **Automático por país** (`DISCOUNT_PER_COUNTRY`): usa la respuesta de país del **primer deportista** (proxy del carrito, como el checkout de grupo) y, solo si el formulario no pregunta país, `clubs.country`. Se normaliza con `CountryNameResolver` (ISO2, nombre en inglés o en español). No se aplica si hay un código.
* **`cumulative`** (`2` = no combinable) rechaza el código en ambas direcciones frente a un descuento por turno.
* **`is_free_code` o 100 %** dejan la base en 0 y además **eximen la tarifa de servicio** (`waivesServiceFee`); cualquier otro descuento solo reduce el valor de la orden.
* El uso se cuenta al crear (orden gratuita) o al confirmarse el pago (`OrderPaymentFinalizer::countDiscountUsageOnce`, idempotente con `_discount_usage_counted`).

## 7. Pagos con tarjeta

* **Nunca** se codifica una pasarela concreta: `pay()` y `persistClubOrder()` redirigen a `payment.start`, que resuelve Redsys o Stripe con `PaymentGatewayResolver` (reglas de `OrganizerPaymentMethod`). El importe se lee siempre de `Order::total_amount`; `PaymentContext::fromRequest()` solo toma `user_name` y `event_name`.
* La **notificación firmada** de la pasarela (webhook) es la única que liquida la orden, vía `OrderPaymentFinalizer::markApproved()` / `markFailed()`. El retorno del navegador es informativo.
* `OrderPaymentFinalizer::fulfillOrder()` tiene una rama `club_portal` (`approveClubPortalRegistrations`) que pasa **todas** las inscripciones a `OK` y suma al contador `eventos.enrolled` solo las que cambiaron (idempotente).
* **Retornos**: `CheckoutController::pendingPaymentReturnPage()` envía a un club de vuelta a `club.order.show` (con un aviso) cuando el retorno de Redsys no está firmado o la firma es inválida; `thanksPage()` y `failedTransactionPage()` tienen rama `club_portal`. La pantalla de Stripe (`checkout/stripe-payment.blade.php`) enlaza "Volver" al detalle de la orden del club en vez de a `/`.

### Correos de confirmación

`OrderPaymentFinalizer::dispatchConfirmationEmails()` recorre **todas** las inscripciones de la orden cuando `orders.is_group` **o** `origin = 'club_portal'` (una orden de club de tarifa individual no es `is_group`, pero cada deportista debe recibir el suyo). Lo hace con `dispatchParticipantConfirmations()`, que despacha **un `SendOrderConfirmationEmail` por inscripción, cada uno en su propio `try/catch`**: con una cola síncrona, un envío que lance una excepción (una dirección inválida, por ejemplo) no impide que los deportistas siguientes reciban el suyo. Es el mismo camino para tarjeta (webhook), transferencia y "Aprobar pago fallido"; las órdenes gratuitas lo llaman desde `persistClubOrder()` (sección 5).

`SendOrderConfirmationEmail` es por inscripción: resuelve el destinatario (usuario de la inscripción → `details.email` → correo del propietario de la orden), adjunta el QR y el PDF de **esa** inscripción y respeta `evento.notify_purchase_email` (si el organizador lo desactivó, no se envía ninguno).

### Sin expiración automática

`AbandonedOrderPolicy::reasonToKeep()` devuelve `club order awaiting payment` para toda orden con `origin = 'club_portal'`, y `orders:expire-pending` (`--hours=48 --gateway=stripe --execute`, diario 03:30) además las excluye de su consulta (`whereNull('origin')->orWhere('origin', '!=', 'club_portal')`), para que no aparezcan como "skip" cada noche. La política es la misma que usa el reemplazo de órdenes pendientes del checkout (`rememberWizardPaymentOrder`), así que también las protege ahí. Una orden del club pendiente **solo se cancela con `cancel()`** (el club) o por decisión del organizador.

### Recuperación de pagos

`pay()` reanuda una orden `PENDING`/`FAILED` sin recalcular nada. Antes, `revalidatePendingOrderForPayment()` comprueba que el evento no haya terminado, que queden inscripciones por pagar, que **el evento tenga cupo** (`eventos.participants` frente a las inscripciones `OK`), que cada tarifa siga activa y con cupo, y que el descuento ligado siga activo, con usos y vigente (si su uso aún no se contó). Una orden `FAILED` vuelve a `PENDING` antes de ir a la pasarela.

Aquí el cupo **bloquea**: quien paga con tarjeta es el club y no puede superar un límite. Los mensajes distinguen "alcanzó el límite / está completa; no quedan cupos para pagar esta orden" de "solo tiene N cupos disponibles y esta orden necesita M", para el evento y para la tarifa. El organizador sí puede confirmar a esos deportistas por encima del cupo (sección 8). Un pago que **ya se cobró** nunca se rechaza por cupo: `markApproved()` promueve todas las inscripciones.

`Order::hasBlockingPaymentAttempt()` bloquea **pagar y cancelar** cuando hay un `PaymentAttempt` `authorized` (sin límite de tiempo) o uno `sent` de hace menos de `PAYMENT_ATTEMPT_IN_FLIGHT_MINUTES = 30`. Aplica igual a Redsys y Stripe: ambos crean el intento con `PaymentAttempt::createForOrder` y lo resuelven a `authorized`/`failed` desde su webhook.

`cancel()` marca la orden `CANCELLED`, las inscripciones pendientes `CANCELADO` (no se borran) y guarda `cancelled_by_club`. Se re-comprueba el estado y el bloqueo bajo `lockForUpdate`.

## 8. Pago por transferencia

### Habilitación

`ClubOrderController::organizerBankTransferDetails()` devuelve los datos solo si el organizador tiene **`banco`, `numero_cuenta` y `correo`** no vacíos; `id_cuenta` es opcional y el titular es `organizadores.nombre` (con el banco como respaldo). No existe un interruptor por organizador: tener los datos activa la opción.

La opción se ofrece solo si `pricing.requires_card_payment` (total > 0). El formulario manual guarda la elección en un campo oculto `payment_method` (`gateway` por defecto) que la ventana de revisión modifica; la pantalla de confirmación del Excel usa un `<fieldset>` de radios. `requestedPayByTransfer()` **re-valida en el servidor** (`payment_method === 'transfer'` **y** datos bancarios presentes), de modo que una petición manipulada no puede dejar en revisión una orden pagable con tarjeta.

### Creación

`createFromClubPortal(..., $payByTransfer)` calcula `$orderStatus = requires_card_payment ? (payByTransfer ? 'PENDING_TRANSFER' : 'PENDING') : 'OK'`. Las inscripciones usan `PENDING` (o `OK` si la orden es gratuita). Tras crear, el club vuelve a `club.order.show`, que pinta las instrucciones (banco, titular, cuenta, ID cuenta y `mailto:` del correo) mientras el estado sea `PENDING_TRANSFER`. `pay()` rechaza esta orden (no está en `RECOVERABLE_ORDER_STATES`); `cancel()` sí la admite.

### Revisión y aprobación (organizador)

`FinancesController::orderDetail()` calcula `isClubPortalOrder`, `canApproveClubTransfer` (`orders_and_participants.edit` + `PENDING_TRANSFER`), `clubTransferApproval`, `clubTransferDraft` y `clubTransferCapacity` (resultado de `PendingOrderCapacityCheck`, solo cuando se puede aprobar). La vista muestra la tarjeta del roster (5 por página, por AJAX contra `apiClubOrderParticipants()`, con `app-pagination-ajax.js`), el modal de aprobación y, después, la tarjeta "Aprobación de transferencia".

Validación de las tres acciones (Guardar y Aprobar comparten reglas): `notes` ≤ 2000, `payment_reference` ≤ 255 y `receipt` opcional `mimes:png,pdf`, `max:5120` KB. Tras un fallo el modal se reabre solo con los errores.

| Acción                 | Efecto                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `saveClubTransferInfo` | Guarda el borrador en `transaction['club_transfer_review']`. No cambia el estado de la orden.                                                                                                                                                                                                                                                                                                                                   |
| `approveClubTransfer`  | Bajo `lockForUpdate` (y con una comprobación previa) **revalida el cupo** (`PendingOrderCapacityCheck`, sección "Cupo al aprobar"), construye un `GatewayNotification` (`gateway = 'bank_transfer'`, `paymentMethodLabel = 'Transferencia bancaria'`, `amountInCents = null`) y llama a **`OrderPaymentFinalizer::markApproved()`**. Acepta `allow_over_capacity` (boolean) además de `notes`, `payment_reference` y `receipt`. |

`resolveClubTransferSubmission()` fusiona lo enviado con el borrador: un campo vacío o un archivo ausente **caen al valor guardado**; un archivo nuevo **reemplaza** y borra el anterior del disco. Así aprobar más tarde no obliga a volver a adjuntar nada.

Reutilizar `markApproved()` es deliberado: da gratis el mismo cumplimiento que un pago con tarjeta (promoción de todas las inscripciones, contador de inscritos, uso del descuento, línea de tiempo de pago y correos de confirmación) y deja la orden en `APPROVED`, que el resto del sistema ya trata como pagada. `amountInCents = null` evita que `absorbGatewayPayload()` re-derive los totales desde un cobro que no existe.

**Decisión de contabilidad:** una transferencia de club es un **ingreso normal con la tarifa de servicio de la plataforma**, así que **no** pasa por `ManualApprovalDiscountService` (el de "Aprobar pago fallido", que cubre la orden con un descuento interno del 100 % para dinero recibido fuera de la plataforma).

**Quién aprueba es obligatorio:** `approved_by` (`auth()->id()`) y `approved_by_name` (`authenticatedUserFullName()`: nombre + apellidos) se toman de la sesión, no de un campo del formulario.

### Cupo al aprobar

Las inscripciones de una orden en revisión siguen en `PENDING` y **no cuentan** para los topes (que solo cuentan `OK`), así que cuando el organizador aprueba, el evento o la tarifa pueden haberse llenado. `App\Services\Orders\PendingOrderCapacityCheck` es la **única** fuente de esa respuesta, para que el aviso que se pinta y la comprobación al aprobar no puedan discrepar:

* `evaluate(Order, lock: false)`: toma las inscripciones de la orden con `status != 'OK'` (las mismas que `approveClubPortalRegistrations()` va a pasar a `OK`), las agrupa por tarifa y delega en `evaluateNeeds()`.
* `evaluateNeeds(eventId, [tarifaId => n], lock: false)`: para plazas que aún no existen como filas (la inscripción que va a crear "Aprobar pago fallido").
* Devuelve `['exceeds' => bool, 'limits' => [{ scope: 'event'|'tariff', name, limit, confirmed, needed, over }]]`, con **una entrada por límite que existe** (`eventos.participants` y/o `tarifas_de_eventos.max_register`; un límite nulo no genera entrada). `over = max(0, confirmed + needed - limit)`. Con `lock: true` cuenta con `lockForUpdate()`, igual que `createFromClubPortal()`.

`approveClubTransfer()` la llama **dentro de la transacción y con la orden bloqueada**. Tiene tres desenlaces:

| Desenlace       | Cuándo                                              | Resultado                                                                                                                                                                                                                              |
| --------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `approved`      | Hay cupo, o se superó y llegó `allow_over_capacity` | Aprueba. Si se superó, guarda `capacity_override` en `manual_club_transfer_approval`.                                                                                                                                                  |
| `over_capacity` | No hay cupo y **no** llegó `allow_over_capacity`    | No aprueba nada. **Conserva lo escrito**: `persistClubTransferDraft()` guarda notas, referencia y comprobante como borrador (el archivo ya está en disco y pudo sustituir al del borrador anterior). Redirige con el error `capacity`. |
| `not_pending`   | Otro administrador ya la resolvió                   | Redirige con el aviso "Esta orden ya no está en revisión por transferencia."                                                                                                                                                           |

**Interfaz** (`order-detail.blade.php`, `club-transfer-capacity-lines.blade.php`, `order-detail.css`):

* Un aviso ámbar **"Cupo alcanzado"** sobre los botones y, en el modal, otro **"Se superará el cupo"** con una tarjeta por límite (**Límite / Confirmados / Esta orden suma / Exceso**) y la casilla `allow_over_capacity` ("Entiendo que se superará el cupo…"). El botón `#approveTransferApproveBtn` está deshabilitado hasta marcarla (el servidor exige lo mismo; el JS solo ahorra una petición).
* El modal se reabre solo si hay errores en `notes`, `payment_reference`, `receipt` **o `capacity`**. Es el caso de una página abierta cuando aún había cupo.
* El bloque comparte el parcial con la fila **"Cupo superado al aprobar"** de la tarjeta "Aprobación de transferencia", que lee `capacity_override`.
* El tema define `.alert` como un `flex` en fila y ponía cada trozo del aviso en su propia columna; `.alert.club-transfer-capacity` lo fuerza a `block`. El modal (`#approveClubTransferModal`) sube a `z-index: 10000` porque la barra fija del sitio (`z-index: 9988`) tapaba su título; el resto de modales de la pantalla no se toca, porque SweetAlert (1060) se abre sobre ellos.

**"Aprobar pago fallido"** (`approveFailedPayment()`) revalida el cupo en **cada rama que confirma inscripciones** — la de grupo o club (`approveAllForOrder()`, que ahora también se usa cuando `origin = 'club_portal'`, de modo que una orden de club de tarifa individual aprueba a **todos** sus deportistas y no solo al primero), la de una inscripción existente que se promueve y la que **crea** la inscripción —; las de tienda, hotel y extras no ocupan plaza. Si no hay cupo y no llegó `allow_over_capacity`, hace `rollBack()` y responde **409** con `capacity_exceeded`, `title`, `message`, `lines`, `hint` y `confirm_label` (ya traducidos). `order-detail.js` no lo trata como un error: muestra un SweetAlert (`lines` como nodos de texto, nunca como HTML: los nombres de tarifa son texto libre) y, si el organizador confirma, repite la petición con `allow_over_capacity`. Un override queda en `manual_failed_payment_approval.capacity_override` y en la cronología (`timeline_event_manual_approval_over_capacity`).

### Comprobante adjunto

Se guarda con `Storage::disk(config('filesystems.default'))->putFileAs('club_transfer_receipts/{evento_id}/{order_id}', …)`. El disco por defecto es `public` en entornos que no son producción/staging y `FILESYSTEM_DISK` (por defecto `s3`) en producción/staging. **Nunca** se expone una URL pública: `downloadClubTransferReceipt()` lo sirve tras comprobar el permiso, con `Content-Disposition: inline` para imágenes (para que la miniatura funcione) y `attachment` para PDF. Sirve el adjunto final o, si aún no se aprobó, el del borrador.

## 9. Puntos de atención conocidos

Comportamientos que conviene tener presentes (algunos vienen de piezas previas al portal):

1. **Órdenes canceladas por la expiración antigua.** Hasta que `orders:expire-pending` excluyó las órdenes del club (sección 7), el proceso nocturno cancelaba las `PENDING` de más de 48 h que habían llegado a Stripe. Esas órdenes conservan `transaction.expired_as_abandoned` y, como el comando solo cambiaba la orden, sus **inscripciones se quedaron en `PENDING`** (a diferencia de `cancel()`, que las pasa a `CANCELADO`). No se corrigen solas: conviene revisarlas (`origin = 'club_portal'` con `transaction->expired_as_abandoned`) y decidir caso por caso.
2. **Sin interruptor por organizador** para la transferencia, y los datos bancarios son los mismos que la organización usa para recibir sus pagos (`banco`, `numero_cuenta`), editables solo por personal de la plataforma en `/admin/organizaciones`.
3. **"Aprobar pago fallido" también aparece** en una orden `PENDING_TRANSFER` (su condición es "no pagada y no de cambio de tarifa"). No es la acción adecuada para una transferencia de club, pero ahora revalida el cupo y aprueba a todos los deportistas de la orden (sección 8).
4. **Los deportistas de una orden pendiente no reservan cupo** (los topes solo cuentan `OK`). Por eso el pago con tarjeta se puede bloquear por cupo (sección 7) y la transferencia avisa al aprobar (sección 8). Un pago ya cobrado nunca se rechaza por esto.
5. **Botones sin permiso.** En el listado, **Pagar** y **Cancelar** se pintan sin comprobar `club.orders.edit`; sin ese permiso, el clic termina en 403.
6. **Alcance v1**: no se soportan campos de subida de archivo obligatorios (la tarifa se bloquea con un aviso), ni tienda ni hotel. Las preguntas `importe` opcionales (extras) no se ofrecen; las obligatorias sí y se cobran por deportista.
7. **Edición de deportistas** (`ClubOrderFormFields`): excluye las preguntas `importe`, solo guarda campos con valor (no se puede vaciar un dato) y actualiza también el perfil del usuario. El país se normaliza a ISO2 antes de pintar (`normalizeCountryDetailForEdit`) y `club-order-participant-edit.js` compara opciones sin distinguir mayúsculas ni tildes, contra su valor y su etiqueta. Bloquea al guardar si el evento terminó o la inscripción está cerrada (`isLifecycleClosed`).
8. **Pruebas.** `Order::createWithSequence()` usa el query builder (`insertOrIgnore` + `lockForUpdate`) y funciona igual en MySQL y en SQLite, así que una prueba de feature puede recorrer la creación real de una orden de club de punta a punta (con `Queue::fake()` para comprobar los correos). Lo que SQLite **no** puede probar es el bloqueo real de filas entre dos administradores simultáneos: `lockForUpdate()` no tiene efecto allí.

## 10. Otras piezas

* **Notificaciones**: `ClubInvitationNotification` (cuenta nueva → `AccountCreationMail` con enlace a `invitation.show` para definir contraseña) y `ClubAdminAddedNotification` (usuario existente, un `MailMessage` estándar).
* **Logo en los correos estándar**: la plantilla `resources/views/vendor/notifications/email.blade.php` (copia de la del framework en la que solo cambia la primera línea) pasa al header un logo **incrustado en el propio mensaje** (parte inline CID) mediante `App\Support\EmbeddedMailLogo::for($message)`. Una imagen remota no carga desde un entorno local o de staging (el cliente de correo no llega a `APP_URL`) y Outlook bloquea las remotas por defecto. La vista se renderiza varias veces por envío (HTML y texto) y `Message::embed()` añade una parte por llamada, así que el CID se recuerda **por mensaje** (`WeakMap`) y se adjunta una sola vez. Sin mensaje (una vista previa con `render()`), el header usa la URL pública como respaldo. Además el header ya no deforma el logo: el tema fijaba `.logo` en 75×75 y el archivo es 1188×211, de modo que ahora se pinta a 240×43 con `width`/`height` explícitos y el color del enlace del header es claro (el texto alternativo se lee sobre el fondo oscuro cuando se bloquean las imágenes). Afecta a **todos** los correos estándar de la plataforma; los que tienen plantilla propia (como la confirmación de compra) no cambian.
* **Panel del organizador**: `ClubAdminController`. Los clubes "de la organización" los resuelve `OrganizationClubResolver::clubIds()` (creados por ella, más los referenciados por inscripciones de sus eventos por `eventos_x_usuarios.club_id` o por el nombre/id en `details`). Es la misma fuente que usa la tarjeta de clubes del dashboard.
* **Exportación**: `ClubOrderParticipantsExport::forOrder()` es la única fuente de las columnas (formulario del evento con columnas de club, sin la pregunta de pertenencia); la usan `ClubOrderController::exportParticipants()` y `FinancesController::exportClubOrderParticipants()`, así ambos Excel de una misma orden no se desalinean.
* **MCP**: la herramienta `list_clubs` (permiso `organizer.clubs.view`), el filtro `club_id` de `list_registrations` y la métrica `club` de `get_event_statistics`.

## 11. Despliegue

* Ejecutar `php artisan migrate` (la migración `2026_09_15_000001_widen_organizador_bank_columns_to_string` convierte las dos columnas bancarias a `VARCHAR(64)` en MySQL).
* No hay variables de entorno ni configuración nuevas. En producción/staging el comprobante usa el disco `FILESYSTEM_DISK` (S3 por defecto); no hace falta `storage:link`, porque los archivos se sirven por el controlador.
* Para activar la transferencia en una organización, cargar **Banco** y **Número de cuenta** (y comprobar el **Correo**) en `/admin/organizaciones`.
* Estos cambios (correos por deportista, cupo al aprobar, exclusión de las órdenes del club del proceso nocturno, logo incrustado) **no llevan migraciones ni variables nuevas**. Tras desplegar, conviene revisar las órdenes de club que la expiración antigua ya canceló (sección 9, punto 1). Los estilos y el JS de `order-detail` se sirven con `?v=filemtime`, así que no requieren limpiar la caché del navegador.

## 12. Archivos clave

| Área                               | Archivo                                                                                                                                                                                                              |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Portal                             | `app/Http/Controllers/Club/ClubDashboardController.php`, `ClubUserController.php`, `ClubOrderController.php`                                                                                                         |
| Creación de orden                  | `app/Services/Orders/GroupOrderCreationService.php`, `ClubOrderExcelParser.php`                                                                                                                                      |
| Campos y descuentos                | `app/Support/ClubOrderFormFields.php`, `ClubOrderDiscount.php`                                                                                                                                                       |
| Pagos y correos                    | `app/Services/Payments/OrderPaymentFinalizer.php` (`dispatchParticipantConfirmations`), `GatewayNotification.php`, `app/Jobs/SendOrderConfirmationEmail.php`                                                         |
| Cupo                               | `app/Services/Orders/PendingOrderCapacityCheck.php`                                                                                                                                                                  |
| Sin expiración de órdenes del club | `app/Support/AbandonedOrderPolicy.php`, `app/Console/Commands/ExpireAbandonedPendingOrdersCommand.php`                                                                                                               |
| Aprobación                         | `app/Http/Controllers/Eventos/FinancesController.php` (`approveClubTransfer`, `approveFailedPayment`, `saveClubTransferInfo`, `persistClubTransferDraft`, `downloadClubTransferReceipt`, `apiClubOrderParticipants`) |
| Logo de correos                    | `app/Support/EmbeddedMailLogo.php`, `resources/views/vendor/notifications/email.blade.php`, `resources/views/vendor/mail/html/{header,message}.blade.php`                                                            |
| Exportación                        | `app/Exports/ClubOrderParticipantsExport.php`                                                                                                                                                                        |
| Organizador                        | `app/Http/Controllers/Clubes/ClubAdminController.php`, `app/Support/OrganizationClubResolver.php`                                                                                                                    |
| Middleware                         | `app/Http/Middleware/CheckClubRole.php`, `CheckClubOrderOwner.php`                                                                                                                                                   |
| Vistas                             | `resources/views/club/**`, `resources/views/clubes/**`, `resources/views/eventos/finances/order-detail.blade.php`, `resources/views/eventos/finances/partials/club-transfer-capacity-lines.blade.php`                |
| Front                              | `public/js/club-order-wizard.js`, `club-order-participant-edit.js`, `app-pagination-ajax.js`, `order-detail.js` (diálogo de cupo de "Aprobar pago fallido"), `public/css/order-detail.css`                           |
| Textos                             | `resources/lang/{es,en}/ui.php` (`approve_failed_payment_capacity_*`, `capacity_limit_event`, `capacity_limit_tariff`, `timeline_event_manual_approval_over_capacity*`)                                              |
