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

# 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

| Actor                 | Role / permission                                  | Scope                                     |
| --------------------- | -------------------------------------------------- | ----------------------------------------- |
| Club Administrator    | **Club** role + active row in `club_user`          | Only the clubs they administer            |
| Organizer             | `organizer.clubs.view` / `organizer.clubs.edit`    | Clubs tied to their organization's events |
| Organizer (approvals) | `organizer.orders_and_participants.view` / `.edit` | View / save and approve transfers         |
| Rocky Global staff    | `administrar-organizaciones`                       | Loads an organization's bank details      |

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

| Middleware               | Guarantee                                                                                                                                                                                |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `check.club.role`        | Club role **and**, when the URL has `{club}`, active administrator of that club (`User::belongsToClub`). Sets the Spatie permission context to `null` (`club.*` permissions are global). |
| `check.club.order.owner` | The URL's `{order}` has a `club_id` belonging to one of the user's clubs.                                                                                                                |
| `can:club.*`             | The specific permission of each route group.                                                                                                                                             |

`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

| Table / column                                                       | Use                                                                                                                                                                     |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clubs` (`name`, `email`, `country`, `organizador_id`, soft deletes) | The club. `organizador_id` is the managing organization (null for legacy clubs).                                                                                        |
| `club_user` (`club_id`, `user_id`, `status`, `invited_by`)           | Administrators. `status`: `invited` \| `active` \| `revoked`. Unique per `(club_id, user_id)`.                                                                          |
| `orders.club_id`, `orders.origin`                                    | `origin = 'club_portal'` marks a portal order; `club_id` ties it to the club.                                                                                           |
| `eventos_x_usuarios.club_id`                                         | Registrations created by the club.                                                                                                                                      |
| `organizadores.banco`, `numero_cuenta`, `id_cuenta`, `correo`        | Details shown to the club to transfer. Migration `2026_09_15_000001` widens `numero_cuenta`/`id_cuenta` from `BIGINT` to `VARCHAR(64)` (MySQL only; a no-op on SQLite). |

`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

```mermaid theme={null}
stateDiagram-v2
    [*] --> OK: total 0 (no charge)
    [*] --> PENDING: card
    [*] --> PENDING_TRANSFER: transfer
    PENDING --> APPROVED: signed gateway notification
    PENDING --> FAILED: rejected or cancelled at the gateway
    FAILED --> PENDING: pay() retries
    PENDING --> CANCELLED: club cancel()
    FAILED --> CANCELLED: club cancel()
    PENDING_TRANSFER --> APPROVED: organizer approves
    PENDING_TRANSFER --> CANCELLED: club cancel()
    APPROVED --> PARTIALLY_CANCELLED: partial refund
    APPROVED --> CANCELADO: full refund
```

| Order status       | Registration status        | Notes                                                                                   |
| ------------------ | -------------------------- | --------------------------------------------------------------------------------------- |
| `OK`               | `OK`                       | Free order: the discount use is counted at creation.                                    |
| `PENDING`          | `PENDING`                  | Awaits the gateway. Retryable (`RECOVERABLE_ORDER_STATES`).                             |
| `PENDING_TRANSFER` | `PENDING`                  | Awaits the organizer. **Not** retryable through a gateway.                              |
| `FAILED`           | `PENDING`                  | Retryable.                                                                              |
| `APPROVED`         | `OK`                       | Payment confirmed or transfer approved.                                                 |
| `CANCELLED`        | `CANCELADO` (pending ones) | Club cancellation. A club order is **never** cancelled by automatic expiry (section 7). |

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

1. `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**.
2. `GroupOrderCreationService::createFromClubPortal()` runs in a transaction: re-checks the price (`assertPricingIntegrity()`), **re-validates quotas** (event and rate, counting only `OK` registrations) and the discount under `lockForUpdate`, then creates the order (`Order::createWithSequence()`, `origin = 'club_portal'`), one `EventoXUsuario` per athlete (existing user reused with profile updated, or new user with the Athlete role), captain and locators per team, and the `OrderItem`s.
3. The initial status comes from `requires_card_payment` and `$payByTransfer` (section 8).
4. 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.

The Excel import (`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()` and `persistClubOrder()` redirect to `payment.start`, which resolves Redsys or Stripe with `PaymentGatewayResolver`. The amount is always read from `Order::total_amount`; `PaymentContext::fromRequest()` only takes `user_name` and `event_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 a `club_portal` branch (`approveClubPortalRegistrations`) that moves **all** registrations to `OK` and increments `eventos.enrolled` only by the ones that changed (idempotent).
* **Returns**: `CheckoutController::pendingPaymentReturnPage()` sends a club back to `club.order.show` (with a notice) when a Redsys return is unsigned or its signature invalid; `thanksPage()` and `failedTransactionPage()` have a `club_portal` branch. The Stripe page links "Back" to the club order detail instead of `/`.

**Confirmation emails.** `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.

| Action                 | Effect                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `saveClubTransferInfo` | Stores the draft in `transaction['club_transfer_review']`. The order status doesn't change.                                                                                                                                                                                                                                                                                                                                     |
| `approveClubTransfer`  | Under `lockForUpdate` (after a first check), **re-validates capacity** (`PendingOrderCapacityCheck`, "Capacity on approval" below), builds a `GatewayNotification` (`gateway = 'bank_transfer'`, `paymentMethodLabel = 'Transferencia bancaria'`, `amountInCents = null`) and calls **`OrderPaymentFinalizer::markApproved()`**. It accepts `allow_over_capacity` (boolean) besides `notes`, `payment_reference` and `receipt`. |

`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 with `status != 'OK'` (the same ones `approveClubPortalRegistrations()` is about to move to `OK`), groups them by rate and delegates to `evaluateNeeds()`.
* `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.participants` and/or `tarifas_de_eventos.max_register`; a null limit yields no entry). `over = max(0, confirmed + needed - limit)`. With `lock: true` it counts with `lockForUpdate()`, like `createFromClubPortal()`.

`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

1. **Orders cancelled by the old expiry.** Until `orders:expire-pending` excluded club orders (section 7), the nightly process cancelled `PENDING` orders older than 48 h that had reached Stripe. Those orders keep `transaction.expired_as_abandoned` and, since the command only changed the order, their **registrations stayed `PENDING`** (unlike `cancel()`, which moves them to `CANCELADO`). They don't fix themselves: review them (`origin = 'club_portal'` with `transaction->expired_as_abandoned`) and decide case by case.
2. **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`.
3. **"Approve failed payment" also shows** on a `PENDING_TRANSFER` order (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).
4. **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.
5. **Buttons without permission.** In the list, **Pay** and **Cancel** are rendered without checking `club.orders.edit`; without it, the click ends in a 403.
6. **v1 scope**: required file-upload fields aren't supported (the rate is blocked with a notice), nor is the store or hotel. Optional `importe` questions (extras) aren't offered; required ones are, and are charged per athlete.
7. **Athlete editing** (`ClubOrderFormFields`): excludes `importe` questions, 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`) and `club-order-participant-edit.js` matches options ignoring case and accents, against value and label. Saving is blocked if the event ended or the registration is closed (`isLifecycleClosed`).
8. **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 (with `Queue::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 → `AccountCreationMail` with an `invitation.show` link to set the password) and `ClubAdminAddedNotification` (existing user, a standard `MailMessage`).
* **Logo in standard emails**: the `resources/views/vendor/notifications/email.blade.php` template (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) through `App\Support\EmbeddedMailLogo::for($message)`. A remote image never loads from a local or staging install (the mail client can't reach `APP_URL`) and Outlook blocks remote ones by default. The view is rendered several times per send (HTML and text) and `Message::embed()` adds one part per call, so the CID is remembered **per message** (`WeakMap`) and attached only once. With no message (a `render()` preview) the header falls back to the public URL. The header also no longer distorts the logo: the theme fixed `.logo` at 75×75 while the file is 1188×211, so it is now drawn at 240×43 with explicit `width`/`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 by `OrganizationClubResolver::clubIds()` (created by it, plus those referenced by registrations of its events through `eventos_x_usuarios.club_id` or the name/id in `details`), the same source as the dashboard's clubs card.
* **Export**: `ClubOrderParticipantsExport::forOrder()` is the single source of columns; used by `ClubOrderController::exportParticipants()` and `FinancesController::exportClubOrderParticipants()`, so both Excel files of one order never drift apart.
* **MCP**: the `list_clubs` tool (`organizer.clubs.view`), the `club_id` filter on `list_registrations` and the `club` metric of `get_event_statistics`.

## 11. Deployment

* Run `php artisan migrate` (migration `2026_09_15_000001_widen_organizador_bank_columns_to_string` converts the two bank columns to `VARCHAR(64)` on MySQL).
* No new environment variables or configuration. In production/staging the proof uses the `FILESYSTEM_DISK` disk (S3 by default); `storage:link` isn'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-detail` styles and JS are served with `?v=filemtime`, so no browser-cache clearing is needed.
