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
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).- Una petición que trae claves monetarias (
total,amount,pricing,discount_id…, listaFORBIDDEN_PRICE_KEYS) se rechaza entera. 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 bajolockForUpdate; - crea la orden con
Order::createWithSequence()(origin = 'club_portal',club_id), unEventoXUsuariopor deportista (usuario existente reutilizado y con el perfil actualizado, o nuevo con rol Deportista),is_captainy localizadores por equipo, y losOrderItem.
- vuelve a comprobar el precio con
- El estado inicial sale de
requires_card_paymenty de$payByTransfer(ver sección 8). - 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.
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, mientrasin_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 conCountryNameResolver(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_codeo 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()ypersistClubOrder()redirigen apayment.start, que resuelve Redsys o Stripe conPaymentGatewayResolver(reglas deOrganizerPaymentMethod). El importe se lee siempre deOrder::total_amount;PaymentContext::fromRequest()solo tomauser_nameyevent_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 ramaclub_portal(approveClubPortalRegistrations) que pasa todas las inscripciones aOKy suma al contadoreventos.enrolledsolo las que cambiaron (idempotente).- Retornos:
CheckoutController::pendingPaymentReturnPage()envía a un club de vuelta aclub.order.show(con un aviso) cuando el retorno de Redsys no está firmado o la firma es inválida;thanksPage()yfailedTransactionPage()tienen ramaclub_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 enPENDING 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 constatus != 'OK'(las mismas queapproveClubPortalRegistrations()va a pasar aOK), las agrupa por tarifa y delega enevaluateNeeds().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.participantsy/otarifas_de_eventos.max_register; un límite nulo no genera entrada).over = max(0, confirmed + needed - limit). Conlock: truecuenta conlockForUpdate(), igual quecreateFromClubPortal().
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#approveTransferApproveBtnestá 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,receiptocapacity. 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
.alertcomo unflexen fila y ponía cada trozo del aviso en su propia columna;.alert.club-transfer-capacitylo fuerza ablock. El modal (#approveClubTransferModal) sube az-index: 10000porque 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.
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 conStorage::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):- Órdenes canceladas por la expiración antigua. Hasta que
orders:expire-pendingexcluyó las órdenes del club (sección 7), el proceso nocturno cancelaba lasPENDINGde más de 48 h que habían llegado a Stripe. Esas órdenes conservantransaction.expired_as_abandonedy, como el comando solo cambiaba la orden, sus inscripciones se quedaron enPENDING(a diferencia decancel(), que las pasa aCANCELADO). No se corrigen solas: conviene revisarlas (origin = 'club_portal'contransaction->expired_as_abandoned) y decidir caso por caso. - 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. - “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). - 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. - Botones sin permiso. En el listado, Pagar y Cancelar se pintan sin comprobar
club.orders.edit; sin ese permiso, el clic termina en 403. - Alcance v1: no se soportan campos de subida de archivo obligatorios (la tarifa se bloquea con un aviso), ni tienda ni hotel. Las preguntas
importeopcionales (extras) no se ofrecen; las obligatorias sí y se cobran por deportista. - Edición de deportistas (
ClubOrderFormFields): excluye las preguntasimporte, 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) yclub-order-participant-edit.jscompara 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). - 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 (conQueue::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 →AccountCreationMailcon enlace ainvitation.showpara definir contraseña) yClubAdminAddedNotification(usuario existente, unMailMessageestá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) medianteApp\Support\EmbeddedMailLogo::for($message). Una imagen remota no carga desde un entorno local o de staging (el cliente de correo no llega aAPP_URL) y Outlook bloquea las remotas por defecto. La vista se renderiza varias veces por envío (HTML y texto) yMessage::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 conrender()), el header usa la URL pública como respaldo. Además el header ya no deforma el logo: el tema fijaba.logoen 75×75 y el archivo es 1188×211, de modo que ahora se pinta a 240×43 conwidth/heightexplí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 resuelveOrganizationClubResolver::clubIds()(creados por ella, más los referenciados por inscripciones de sus eventos poreventos_x_usuarios.club_ido por el nombre/id endetails). 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 usanClubOrderController::exportParticipants()yFinancesController::exportClubOrderParticipants(), así ambos Excel de una misma orden no se desalinean. - MCP: la herramienta
list_clubs(permisoorganizer.clubs.view), el filtroclub_iddelist_registrationsy la métricaclubdeget_event_statistics.
11. Despliegue
- Ejecutar
php artisan migrate(la migración2026_09_15_000001_widen_organizador_bank_columns_to_stringconvierte las dos columnas bancarias aVARCHAR(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 faltastorage: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-detailse sirven con?v=filemtime, así que no requieren limpiar la caché del navegador.
