Club portal (Club Administrator)
The club portal (/dashboard-club) lets a club administrator register several athletes at once for the events of one organization and pay everything in a single charge, by card (Redsys/Stripe) or by bank transfer manually approved by the organizer.
It reuses existing pieces rather than duplicating them: the checkout’s pricing and discount engine, payment.start for gateways, and OrderPaymentFinalizer to mark an order as paid.
1. Actors, roles and permissions
Club role permissions (migration
2026_09_10_000002): club.orders.view, club.orders.edit (create, import, pay, cancel, edit) and club.users.manage. A custom role can get a subset from /admin/roles.
ClubOrderController::assertEventInClubOrganization() also blocks registering for an event of another organization (404) when the club has an organizador_id. Legacy clubs with a null organizador_id are unrestricted until assigned.
2. Data model
orders.transaction (JSON) keys used by the club flow: payment_method ("Transferencia bancaria"), club_transfer_review (draft saved with Save: notes, payment_reference, attachment, saved_by, saved_by_name, saved_at), manual_club_transfer_approval (final approval: approved_by, approved_by_name — first and last name —, at, notes, payment_reference, attachment and, only if it was approved past a limit, capacity_override: a list of { scope, name, limit, confirmed, needed, over }), manual_failed_payment_approval (written by “Approve failed payment”: approved_at, approved_by_user_id and, when approved past a limit, capacity_override with the same shape), cancelled_by_club, expired_as_abandoned (only on old orders that the nightly process cancelled when it still reached club orders — section 9; no club order gets it today) and _discount_usage_counted. attachment has the same shape as checkout form attachments: { path, disk, original_name, mime_type, size }.
3. Routes
Club portal (/dashboard-club, auth + check.club.role): club.dashboard; club.users.index|invite|revoke (club.users.manage); club.dashboard.events, club.order.create|fields|template|import|import.confirm|preview|store, club.order.participant.edit|update, club.order.pay|cancel (club.orders.edit; the last four with the order-owner middleware); club.order.index, club.order.show|export|receipt (club.orders.view, with the order-owner middleware).
Organizer: organizer.clubs.* at /organizer/clubes (organizer.clubs.view/edit), and under /events/{id}/finances/orders/{orderId}: saveClubTransferInfo (POST /save-club-transfer-info), approveClubTransfer (POST /approve-club-transfer) — both orders_and_participants.edit —, transferReceipt, exportClubParticipants and apiClubParticipants (GET, orders_and_participants.view).
4. Order lifecycle
Registrations of an order in transfer review stay
PENDING on purpose: capacity checks, dashboards and exports already read that value as “not enrolled yet”, and a new per-registration status would require auditing every place EventoXUsuario::status is read. The “how it will be paid” distinction lives only in orders.status.
ClubOrderController keeps two sets: RECOVERABLE_ORDER_STATES = [PENDING, FAILED] (what pay() can retry) and UNRESOLVED_ORDER_STATES = [PENDING, FAILED, PENDING_TRANSFER] (what is listed under “To pay” and can be cancelled).
5. Order creation
CheckoutController::validateAndPriceClubGroupOrder()validates athletes against the event’s fields and prices always on the server. A request carrying monetary keys (FORBIDDEN_PRICE_KEYS) is rejected entirely.GroupOrderCreationService::createFromClubPortal()runs in a transaction: re-checks the price (assertPricingIntegrity()), re-validates quotas (event and rate, counting onlyOKregistrations) and the discount underlockForUpdate, then creates the order (Order::createWithSequence(),origin = 'club_portal'), oneEventoXUsuarioper athlete (existing user reused with profile updated, or new user with the Athlete role), captain and locators per team, and theOrderItems.- The initial status comes from
requires_card_paymentand$payByTransfer(section 8). - If the order is free (
OK),persistClubOrder()dispatches, outside the transaction, one confirmation email per athlete (OrderPaymentFinalizer::dispatchParticipantConfirmations()): there is no later payment to send them.
ClubOrderExcelParser, MAX_ROWS = 300, .xlsx/.xls up to 5 MB) validates the whole file before accepting anything, keeps the rows in the session (club_order_import:{club}:{event}, never in HTML) and reaches the same persistClubOrder() as the manual form.
6. Discount engine (App\Support\ClubOrderDiscount)
Parity with the public checkout: manual code (DISCOUNT_PER_CODE, case-insensitive; event, active, rate applicability, quota and date window), automatic per turn (DISCOUNT_PER_TURN, while in_use < quantity, silent when it doesn’t apply), automatic per country (DISCOUNT_PER_COUNTRY, from the first athlete’s country answer, falling back to clubs.country only if the form doesn’t ask the country; normalized with CountryNameResolver; not applied when a code is present), cumulative (2 = not combinable) rejecting the code in both directions against a turn discount, and is_free_code or 100% zeroing the base and waiving the service fee (waivesServiceFee). Any other discount only reduces the order value. Usage is counted at creation (free order) or when payment is confirmed (countDiscountUsageOnce, idempotent through _discount_usage_counted).
7. Card payments
- A specific gateway is never hardcoded:
pay()andpersistClubOrder()redirect topayment.start, which resolves Redsys or Stripe withPaymentGatewayResolver. The amount is always read fromOrder::total_amount;PaymentContext::fromRequest()only takesuser_nameandevent_name. - The gateway’s signed notification (webhook) is the only thing that settles an order, through
OrderPaymentFinalizer::markApproved()/markFailed(). The browser return is informational. OrderPaymentFinalizer::fulfillOrder()has aclub_portalbranch (approveClubPortalRegistrations) that moves all registrations toOKand incrementseventos.enrolledonly by the ones that changed (idempotent).- Returns:
CheckoutController::pendingPaymentReturnPage()sends a club back toclub.order.show(with a notice) when a Redsys return is unsigned or its signature invalid;thanksPage()andfailedTransactionPage()have aclub_portalbranch. The Stripe page links “Back” to the club order detail instead of/.
OrderPaymentFinalizer::dispatchConfirmationEmails() loops over all the order’s registrations when orders.is_group or origin = 'club_portal' (an individual-rate club order is not is_group, yet every athlete is owed their own email). It does so through dispatchParticipantConfirmations(), which dispatches one SendOrderConfirmationEmail per registration, each in its own try/catch: with a synchronous queue, a send that throws (an invalid address, say) does not stop the athletes after it from getting theirs. It is the same path for card (webhook), transfer and “Approve failed payment”; free orders call it from persistClubOrder() (section 5). SendOrderConfirmationEmail is per registration: it resolves the recipient (the registration’s user → details.email → the order owner’s email), attaches that registration’s QR and PDF, and honours evento.notify_purchase_email (if the organizer turned it off, none is sent).
No auto-expiry. AbandonedOrderPolicy::reasonToKeep() returns club order awaiting payment for every order with origin = 'club_portal', and orders:expire-pending (--hours=48 --gateway=stripe --execute, daily 03:30) also excludes them from its query (whereNull('origin')->orWhere('origin', '!=', 'club_portal')) so they don’t show up as “skip” every night. The same policy backs the checkout’s superseding of pending orders (rememberWizardPaymentOrder), so it protects them there too. A pending club order is only cancelled through cancel() (the club) or by the organizer’s decision.
Recovery. pay() resumes a PENDING/FAILED order without recomputing anything, after revalidatePendingOrderForPayment() checks that the event hasn’t ended, registrations remain unpaid, the event has room (eventos.participants against OK registrations), each rate is active with quota, and the attached discount is still active, has uses and is valid (if its use wasn’t counted yet). A FAILED order goes back to PENDING first. Here capacity blocks: the club pays by card and can’t exceed a limit. Messages tell “reached its limit / is full; no spots left to pay this order” from “only has N spots available and this order needs M”, for the event and for the rate. The organizer can confirm those athletes past the limit (section 8). A payment that was already charged is never refused for capacity: markApproved() promotes all registrations. Order::hasBlockingPaymentAttempt() blocks paying and cancelling when a PaymentAttempt is authorized (no time limit) or sent less than PAYMENT_ATTEMPT_IN_FLIGHT_MINUTES = 30 ago, identically for Redsys and Stripe. cancel() marks the order CANCELLED and pending registrations CANCELADO (never deleted), re-checking status and blocking under lockForUpdate.
8. Bank transfer payment
Enablement.ClubOrderController::organizerBankTransferDetails() returns data only if the organizer has non-empty banco, numero_cuenta and correo; id_cuenta is optional and the holder is organizadores.nombre (bank as fallback). There is no per-organizer switch. The option is offered only if pricing.requires_card_payment (total > 0). requestedPayByTransfer() re-validates on the server (payment_method === 'transfer' and bank details present), so a tampered request can’t park a card-payable order in review.
Creation. createFromClubPortal(..., $payByTransfer) sets PENDING_TRANSFER (registrations PENDING). club.order.show renders the instructions while the status is PENDING_TRANSFER. pay() refuses it; cancel() accepts it.
Review and approval. FinancesController::orderDetail() computes isClubPortalOrder, canApproveClubTransfer (orders_and_participants.edit + PENDING_TRANSFER), clubTransferApproval, clubTransferDraft and clubTransferCapacity (the PendingOrderCapacityCheck result, only when the order can be approved). The view shows the roster card (5 per page, AJAX against apiClubOrderParticipants() with app-pagination-ajax.js), the approval modal and, afterwards, the “Transfer approval” card. Validation (shared by Save and Approve): notes ≤ 2000, payment_reference ≤ 255, optional receipt mimes:png,pdf, max:5120 KB; on failure the modal reopens itself with the errors.
resolveClubTransferSubmission() merges the submission with the draft: an empty field or missing file falls back to the saved value; a new file replaces and deletes the previous one from disk.
Reusing markApproved() is deliberate: it gives the same fulfillment as a card payment for free (all registrations promoted, enrolled counter, discount use, payment timeline entry and confirmation emails) and leaves the order APPROVED. amountInCents = null stops absorbGatewayPayload() from re-deriving totals from a charge that doesn’t exist.
Accounting decision: a club transfer is normal revenue with the platform service fee, so it does not go through ManualApprovalDiscountService (the one behind “Approve failed payment”, which covers the order with an internal 100% discount for money received outside the platform). Who approves is mandatory: approved_by (auth()->id()) and approved_by_name (authenticatedUserFullName(), first + last name) come from the session, not from a form field.
Capacity on approval. Registrations of an order in review stay PENDING and don’t count toward caps (which only count OK), so by the time the organizer approves, the event or a rate may have filled up. App\Services\Orders\PendingOrderCapacityCheck is the single source of that answer, so the notice that is drawn and the check at approval can’t disagree:
evaluate(Order, lock: false)takes the order’s registrations withstatus != 'OK'(the same onesapproveClubPortalRegistrations()is about to move toOK), groups them by rate and delegates toevaluateNeeds().evaluateNeeds(eventId, [tarifaId => n], lock: false)covers places that don’t exist as rows yet (the registration “Approve failed payment” is about to create).- It returns
['exceeds' => bool, 'limits' => [{ scope: 'event'|'tariff', name, limit, confirmed, needed, over }]], one entry per limit that exists (eventos.participantsand/ortarifas_de_eventos.max_register; a null limit yields no entry).over = max(0, confirmed + needed - limit). Withlock: trueit counts withlockForUpdate(), likecreateFromClubPortal().
approveClubTransfer() calls it inside the transaction, with the order locked, and has three outcomes: approved (there is room, or it was exceeded and allow_over_capacity arrived; if exceeded, capacity_override is stored in manual_club_transfer_approval); over_capacity (no room and no allow_over_capacity: nothing is approved and what was typed is kept — persistClubTransferDraft() saves notes, reference and proof as the draft, since the file is already on disk and may have replaced the previous draft’s — and it redirects with the capacity error); and not_pending (another administrator already resolved it: redirect with “Esta orden ya no está en revisión por transferencia.”).
UI (order-detail.blade.php, club-transfer-capacity-lines.blade.php, order-detail.css): an amber “Cupo alcanzado” notice above the buttons and, in the modal, another “Se superará el cupo” with one card per limit (Limit / Confirmed / This order adds / Excess) and the allow_over_capacity checkbox. #approveTransferApproveBtn is disabled until it is ticked (the server requires the same; the JS only saves a round trip). The modal reopens itself on errors in notes, payment_reference, receipt or capacity — the case of a page opened while there was still room. The block shares its partial with the “Cupo superado al aprobar” row of the “Transfer approval” card, which reads capacity_override. The theme defines .alert as a flex row, which put every piece of the notice in its own column; .alert.club-transfer-capacity forces block. The modal (#approveClubTransferModal) goes to z-index: 10000 because the site’s fixed header (z-index: 9988) covered its title; the screen’s other modals are left alone since SweetAlert (1060) opens on top of them.
“Approve failed payment” (approveFailedPayment()) re-validates capacity in every branch that confirms registrations — the group/club one (approveAllForOrder(), which is now also used when origin = 'club_portal', so an individual-rate club order approves all its athletes rather than only the first), the one that promotes an existing registration, and the one that creates the registration —; store, hotel and extras orders take no place. With no room and no allow_over_capacity, it rolls back and answers 409 with capacity_exceeded, title, message, lines, hint and confirm_label (already translated). order-detail.js doesn’t treat that as an error: it shows a SweetAlert (lines as text nodes, never HTML: rate names are free text) and, if the organizer confirms, repeats the request with allow_over_capacity. An override is stored in manual_failed_payment_approval.capacity_override and in the timeline (timeline_event_manual_approval_over_capacity).
Attachment. Stored with Storage::disk(config('filesystems.default'))->putFileAs('club_transfer_receipts/{event_id}/{order_id}', …); the default disk is public outside production/staging and FILESYSTEM_DISK (default s3) there. No public URL is ever exposed: downloadClubTransferReceipt() serves it after the permission check, inline for images (so the thumbnail works) and attachment for PDF, serving the final attachment or, before approval, the draft’s.
9. Known points of attention
- Orders cancelled by the old expiry. Until
orders:expire-pendingexcluded club orders (section 7), the nightly process cancelledPENDINGorders older than 48 h that had reached Stripe. Those orders keeptransaction.expired_as_abandonedand, since the command only changed the order, their registrations stayedPENDING(unlikecancel(), which moves them toCANCELADO). They don’t fix themselves: review them (origin = 'club_portal'withtransaction->expired_as_abandoned) and decide case by case. - No per-organizer switch for transfers, and the bank details are the same ones the organization uses to receive its payouts (
banco,numero_cuenta), editable only by platform staff at/admin/organizaciones. - “Approve failed payment” also shows on a
PENDING_TRANSFERorder (its condition is “not paid and not a rate change”). It isn’t the right action for a club transfer, but it now re-validates capacity and approves all the order’s athletes (section 8). - Athletes of a pending order don’t reserve a spot (caps only count
OK). That is why card payment can be blocked for capacity (section 7) and a transfer warns on approval (section 8). A payment that was already charged is never refused for this. - Buttons without permission. In the list, Pay and Cancel are rendered without checking
club.orders.edit; without it, the click ends in a 403. - v1 scope: required file-upload fields aren’t supported (the rate is blocked with a notice), nor is the store or hotel. Optional
importequestions (extras) aren’t offered; required ones are, and are charged per athlete. - Athlete editing (
ClubOrderFormFields): excludesimportequestions, saves only fields with a value (a value can’t be cleared) and also updates the user’s profile. Country is normalized to ISO2 before rendering (normalizeCountryDetailForEdit) andclub-order-participant-edit.jsmatches options ignoring case and accents, against value and label. Saving is blocked if the event ended or the registration is closed (isLifecycleClosed). - Tests.
Order::createWithSequence()uses the query builder (insertOrIgnore+lockForUpdate) and works the same on MySQL and SQLite, so a feature test can walk through real club order creation end to end (withQueue::fake()to check the emails). What SQLite can’t test is real row locking between two simultaneous administrators:lockForUpdate()has no effect there.
10. Other pieces
- Notifications:
ClubInvitationNotification(new account →AccountCreationMailwith aninvitation.showlink to set the password) andClubAdminAddedNotification(existing user, a standardMailMessage). - Logo in standard emails: the
resources/views/vendor/notifications/email.blade.phptemplate (a copy of the framework’s in which only the first line changes) passes the header a logo embedded in the message itself (an inline CID part) throughApp\Support\EmbeddedMailLogo::for($message). A remote image never loads from a local or staging install (the mail client can’t reachAPP_URL) and Outlook blocks remote ones by default. The view is rendered several times per send (HTML and text) andMessage::embed()adds one part per call, so the CID is remembered per message (WeakMap) and attached only once. With no message (arender()preview) the header falls back to the public URL. The header also no longer distorts the logo: the theme fixed.logoat 75×75 while the file is 1188×211, so it is now drawn at 240×43 with explicitwidth/height, and the header link colour is light (the alt text reads on the dark background when images are blocked). It affects all the platform’s standard emails; those with their own template (like the purchase confirmation) don’t change. - Organizer panel:
ClubAdminController. An organization’s clubs are resolved byOrganizationClubResolver::clubIds()(created by it, plus those referenced by registrations of its events througheventos_x_usuarios.club_idor the name/id indetails), the same source as the dashboard’s clubs card. - Export:
ClubOrderParticipantsExport::forOrder()is the single source of columns; used byClubOrderController::exportParticipants()andFinancesController::exportClubOrderParticipants(), so both Excel files of one order never drift apart. - MCP: the
list_clubstool (organizer.clubs.view), theclub_idfilter onlist_registrationsand theclubmetric ofget_event_statistics.
11. Deployment
- Run
php artisan migrate(migration2026_09_15_000001_widen_organizador_bank_columns_to_stringconverts the two bank columns toVARCHAR(64)on MySQL). - No new environment variables or configuration. In production/staging the proof uses the
FILESYSTEM_DISKdisk (S3 by default);storage:linkisn’t needed because files are served through the controller. - To enable transfers for an organization, load Bank and Account number (and check the Email) at
/admin/organizaciones. - These changes (per-athlete emails, capacity on approval, club orders excluded from the nightly process, embedded logo) need no migrations or new variables. After deploying, review the club orders the old expiry already cancelled (section 9, point 1). The
order-detailstyles and JS are served with?v=filemtime, so no browser-cache clearing is needed.
