Skip to main content

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) y, para el organizador, Gestionar clubes y 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

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: 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

orders.transaction (JSON) guarda, entre otras, estas claves del flujo de club: 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)

Organizador

4. Ciclo de vida de una orden de club

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. 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: 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