openapi: 3.1.0

info:
  title: Dashboll API
  version: "1.0"
  summary: Multi-tenant padel academy management API.
  description: |
    The Dashboll API powers the [Dashboll](https://dashboll.com) Flutter
    apps and any third-party integration that wants to consume an academy's
    schedule, payments, or progress data.

    ## Auth

    All `/api/v1/*` endpoints require a JWT access token via the
    `Authorization: Bearer <token>` header. Tokens are obtained from
    `POST /api/v1/auth/sign_in` and refreshed via
    `POST /api/v1/auth/refresh`. See the **Authentication** section in
    the rendered docs intro for the full flow.

    ## Multi-tenancy

    Every resource is scoped to an academy. Send the academy id in the
    `X-Academy-Id` header on every request after sign-in; the auth
    response (`/auth/me`, `/auth/sign_in`) lists the academies the
    identity has access to.

    ## Rate limits

    Public endpoints (sign-in, sign-up, password reset) are limited to
    30 req/min per IP. Authenticated endpoints are limited to 600 req/min
    per identity. Webhook endpoints are exempt — Stripe and WhatsApp
    drive their own rate budgets.

  contact:
    name: Dashboll Engineering
    email: api@dashboll.com
    url: https://dashboll.com/contact
  license:
    name: Proprietary
    url: https://dashboll.com/terms

servers:
  - url: https://api.dashboll.com
    description: Production
  - url: http://100.73.113.108:3000
    description: Internal dev (Tailscale)

tags:
  - name: Auth
    description: Sign-in, sign-up, refresh, identity profile updates.
  - name: Academies
    description: Academy resource (single tenant root).
  - name: Players
    description: Player profiles, classes, packs, wallets.
  - name: Coaches
    description: Coach profiles + availability.
  - name: Classes
    description: Group + private class records and attendance.
  - name: Notifications
    description: Push tokens + per-identity inbox.
  - name: SessionRequests
    description: |
      Player-initiated class requests + the two-step proposal flow.
      The director's "accept" creates a `proposed` ClassRecord; the
      player then accepts-and-pays (Stripe Payment Sheet or wallet)
      or rejects, and a webhook flips the class to `scheduled`.

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT issued by `POST /api/v1/auth/sign_in`. Send as
        `Authorization: Bearer <access_token>`. Tokens expire after
        24h; use the refresh token to obtain a new pair without
        re-prompting the user.
    AcademyHeader:
      type: apiKey
      in: header
      name: X-Academy-Id
      description: |
        UUID of the academy the request is scoped to. Required on
        every authenticated endpoint after the identity has chosen an
        academy from the list returned by `auth/me`.

  schemas:
    AgingBucket:
      type: object
      properties:
        count:  { type: integer }
        amount: { type: number }

    Identity:
      type: object
      properties:
        id:         { type: string, format: uuid }
        email:      { type: string, format: email }
        first_name: { type: string }
        last_name:  { type: string }
        full_name:  { type: string }
        avatar_url: { type: string, nullable: true }

    SessionResponse:
      type: object
      required: [access_token, refresh_token, expires_at, identity, academies]
      properties:
        access_token:  { type: string, description: JWT, encoded with HS256 }
        refresh_token: { type: string, description: Raw hex string. Hashed on the server. }
        expires_at:    { type: string, format: date-time }
        identity:      { $ref: '#/components/schemas/Identity' }
        academies:
          type: array
          items: { $ref: '#/components/schemas/AcademyMembership' }

    AcademyMembership:
      type: object
      properties:
        id:                 { type: string, format: uuid }
        name:               { type: string }
        slug:               { type: string }
        role:               { type: string, enum: [director, coach, player, gerente, organizador, staff, manager] }
        membership_id:      { type: string, format: uuid, nullable: true }
        coach_profile_id:   { type: string, format: uuid, nullable: true }
        player_profile_id:  { type: string, format: uuid, nullable: true }
        teaches:            { type: boolean, description: True when the membership has a coach_profile attached. }
        stripe_connected:   { type: boolean }
        managed_profiles:
          type: array
          items: { $ref: '#/components/schemas/PlayerProfileSummary' }

    PlayerProfileSummary:
      type: object
      properties:
        id:            { type: string, format: uuid }
        first_name:    { type: string }
        last_name:     { type: string }
        full_name:     { type: string }
        avatar_url:    { type: string, nullable: true }
        padel_level:   { type: integer, minimum: 1, maximum: 10, nullable: true }
        status:        { type: string, enum: [active, archived, pending] }
        date_of_birth: { type: string, format: date, nullable: true }

    RecoverableCreditBucket:
      type: object
      required: [count, next_expiry]
      properties:
        count:
          type: integer
          minimum: 0
          description: Remaining sessions across all `recovery_*` ClassPacks of this bucket.
        next_expiry:
          type: string
          format: date-time
          nullable: true
          description: Earliest expires_at across the bucket's packs, or null when empty.

    PrivateRecoverySlot:
      type: object
      required: [coach, date, day_of_week, starts_at, ends_at, duration_minutes]
      properties:
        coach:
          type: object
          required: [id, full_name]
          properties:
            id:        { type: string, format: uuid, description: Coach Membership ID. }
            full_name: { type: string }
        date:        { type: string, format: date }
        day_of_week:
          type: string
          enum: [monday, tuesday, wednesday, thursday, friday, saturday, sunday]
        starts_at:        { type: string, format: date-time }
        ends_at:          { type: string, format: date-time }
        duration_minutes: { type: integer, minimum: 15 }

    Academy:
      type: object
      properties:
        id:        { type: string, format: uuid }
        name:      { type: string }
        slug:      { type: string }
        city:      { type: string }
        country:   { type: string }
        currency:  { type: string, example: EUR }
        timezone:  { type: string, example: Europe/Madrid }

    ClassRecord:
      type: object
      properties:
        id:            { type: string, format: uuid }
        academy_id:    { type: string, format: uuid }
        group_id:      { type: string, format: uuid, nullable: true }
        coach_id:      { type: string, format: uuid, nullable: true }
        court_id:      { type: string, format: uuid, nullable: true }
        title:         { type: string }
        starts_at:     { type: string, format: date-time }
        ends_at:       { type: string, format: date-time }
        status:
          type: string
          enum: [proposed, scheduled, confirmed, cancelled, completed]
          description: |
            `proposed` (card #9857121355) is the initial state for
            classes that came out of `propose_class!`. The slot is
            reserved on the agenda but the player still has to
            accept-and-pay before the class becomes `scheduled`.
        max_players:   { type: integer }
        price_cents:   { type: integer, nullable: true }
        payment_method:
          type: string
          enum: [subscription, pack, wallet, stripe_one_off, cash]
          nullable: true
        category_id:   { type: string, format: uuid, nullable: true }

    OpenClass:
      type: object
      description: |
        Player-facing projection of a `class_type=open` ClassRecord
        emitted by `GET /api/v1/open_classes`. Includes `payment_methods`
        tailored to the calling player so the Flutter PaymentSheet can
        render without a second hit.
      required: [id, class_type, starts_at, ends_at, max_players, available_spots, payment_methods]
      properties:
        id:               { type: string, format: uuid }
        title:            { type: string, nullable: true }
        class_type:       { type: string, enum: [open] }
        starts_at:        { type: string, format: date-time }
        ends_at:          { type: string, format: date-time }
        duration_minutes: { type: integer }
        max_players:      { type: integer }
        available_spots:  { type: integer }
        price_cents:      { type: integer, nullable: true }
        category:
          type: object
          nullable: true
          properties:
            id:    { type: string, format: uuid }
            name:  { type: string }
            color: { type: string, nullable: true }
        court:
          type: object
          nullable: true
          properties:
            id:   { type: string, format: uuid }
            name: { type: string }
        coach:
          type: object
          nullable: true
          properties:
            id:   { type: string, format: uuid }
            name: { type: string, nullable: true }
        payment_methods:
          type: array
          description: Methods this player can use right now.
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                enum: [class_pack, wallet, stripe_one_off, cash]
              # class_pack-only fields
              id:                 { type: string, format: uuid, nullable: true }
              label:              { type: string, nullable: true }
              recovery:           { type: boolean, nullable: true }
              sessions_remaining: { type: integer, nullable: true }
              expires_at:         { type: string, format: date-time, nullable: true }
              # wallet-only field
              balance:            { type: number, nullable: true }
              # stripe-only field
              amount_cents:       { type: integer, nullable: true }

    OpenClassJoin:
      type: object
      description: |
        Response of `POST /api/v1/classes/:class_id/joins`. The seat is
        reserved (`status=confirmed`) immediately; payment may be sync
        (wallet/pack) or async (stripe webhook completes the paid
        status; cash flips to `cash_pending_confirmation`).
      required: [id, class_id, status, payment_method, payment_status]
      properties:
        id:             { type: string, format: uuid }
        class_id:       { type: string, format: uuid }
        status:         { type: string, enum: [confirmed, cancelled] }
        payment_method: { type: string, enum: [pack, wallet, stripe_one_off, cash], nullable: true }
        payment_status: { type: string, enum: [pending, paid, cash_pending_confirmation] }
        paid_at:        { type: string, format: date-time, nullable: true }
        class_pack_id:  { type: string, format: uuid, nullable: true }
        stripe_client_secret:
          type: string
          nullable: true
          description: Present only when payment_method=stripe_one_off.
        stripe_payment_intent:
          type: object
          nullable: true
          properties:
            id:           { type: string }
            amount_cents: { type: integer }

    SessionRequest:
      type: object
      required: [id, academy_id, player_profile_id, requested_type, status]
      properties:
        id:                { type: string, format: uuid }
        academy_id:        { type: string, format: uuid }
        player_profile_id: { type: string, format: uuid }
        class_id:          { type: string, format: uuid, nullable: true }
        requested_type:
          type: string
          enum: [private, group_join, match, double, private_recovery, group_recovery]
        preferred_date:    { type: string, format: date }
        preferred_time:    { type: string, nullable: true }
        status:
          type: string
          enum: [pending, proposed, confirmed, rejected, player_rejected, cancelled]
          description: |
            * `pending` — submitted, awaiting director review.
            * `proposed` (card #9857121355) — director created a
              proposed class; awaiting player accept-and-pay.
            * `confirmed` — class scheduled and (if non-recovery)
              paid for.
            * `rejected` — director rejected (reason required).
            * `player_rejected` — player rejected the proposal.
            * `cancelled` — director / coach cancelled the proposal,
              or the cron expired it after `class.starts_at` passed.
        rejection_reason:  { type: string, nullable: true }
        notes:             { type: string, nullable: true }
        created_at:        { type: string, format: date-time }

    SessionRequestWithPaymentIntent:
      type: object
      properties:
        session_request: { $ref: '#/components/schemas/SessionRequest' }
        payment_intent:
          type: object
          properties:
            id:            { type: string, format: uuid }
            client_secret: { type: string }
            amount_cents:  { type: integer }
            status:        { type: string }

    PlayerHomeSummary:
      type: object
      required: [player_profile_id, pending_session_requests, active_proposals, wallet_balance]
      properties:
        player_profile_id: { type: string, format: uuid }
        pending_session_requests:
          type: array
          items: { $ref: '#/components/schemas/SessionRequest' }
        active_proposals:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/SessionRequest'
              - type: object
                properties:
                  class_record:
                    type: object
                    nullable: true
                    properties:
                      id:             { type: string, format: uuid }
                      starts_at:      { type: string, format: date-time }
                      ends_at:        { type: string, format: date-time }
                      price_cents:    { type: integer, nullable: true }
                      payment_method: { type: string, nullable: true }
                      court:
                        type: object
                        nullable: true
                        properties:
                          id:   { type: string, format: uuid }
                          name: { type: string }
                      coach:
                        type: object
                        nullable: true
                        properties:
                          id:         { type: string, format: uuid }
                          name:       { type: string }
                          avatar_url: { type: string, nullable: true }
        wallet_balance:
          type: object
          properties:
            balance:   { type: number, format: float }
            currency:  { type: string, example: EUR }
            wallet_id: { type: string, format: uuid, nullable: true }

    Communication:
      type: object
      required: [id, academy_id, communication_type, title, body, pinned, published, audience_criteria]
      properties:
        id:                 { type: string, format: uuid }
        academy_id:         { type: string, format: uuid }
        communication_type: { type: string, enum: [novedad, evento, mantenimiento, aviso, oferta] }
        title:              { type: string }
        body:               { type: string }
        pinned:             { type: boolean }
        published_at:       { type: string, format: date-time, nullable: true }
        published:          { type: boolean }
        audience_criteria:  { type: object, additionalProperties: true }
        views_count:        { type: integer }
        recipients_count:   { type: integer, nullable: true }
        author_id:          { type: string, format: uuid }
        cta_label:
          type: string
          nullable: true
          description: Optional call-to-action label rendered as a button in the detail view.
        image_url:
          type: string
          format: uri
          nullable: true
          description: Active Storage signed URL for the hero image. Null when no image is attached.
        linked:
          type: object
          nullable: true
          description: |
            Optional link to an academy entity (Stage, Group or ClassRecord) that the
            CTA navigates to. The polymorphic relation is whitelisted server-side and
            validated to belong to the same academy as the communication.
          required: [type, id]
          properties:
            type:      { type: string, enum: [Stage, Group, ClassRecord] }
            id:        { type: string, format: uuid }
            name:      { type: string, nullable: true }
            starts_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:    { type: string, example: unprocessable_entity }
            message: { type: string }
            details: { type: object, additionalProperties: true }

  parameters:
    AcademyIdHeader:
      in: header
      name: X-Academy-Id
      required: true
      schema: { type: string, format: uuid }
      description: UUID of the academy the request is scoped to.

  responses:
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Authenticated but not allowed to access this resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource does not exist or is not visible to the caller.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: Malformed request body or missing required wrapper.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: Validation error (422). The errors array lists each failing rule.
      content:
        application/json:
          schema:
            type: object
            properties:
              errors:
                type: array
                items: { type: string }

security:
  - BearerAuth: []
  - AcademyHeader: []

paths:
  /api/v1/open_classes:
    get:
      tags: [Classes]
      summary: List open classes the calling player can join.
      description: |
        Player-facing feed for the home "Clases abiertas" section
        (card #9860136295). Returns future `class_type=open` classes
        in the current academy where the player passes
        `CategoryEligibility` and at least one seat is free. Each row
        includes the `payment_methods` available for that specific
        player so the Flutter PaymentSheet renders without a second
        round-trip.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  open_classes:
                    type: array
                    items: { $ref: "#/components/schemas/OpenClass" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/classes/{class_id}/joins:
    parameters:
      - in: path
        name: class_id
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Classes]
      summary: Self-enrol the calling player into an open class.
      description: |
        Atomic seat reservation. Validates eligibility, locks the
        class row, re-counts free seats, then creates the Attendance
        and applies the chosen payment method in one transaction.
        Returns 403 when not eligible, 409 on capacity / duplicate,
        422 on pack/payment failures.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              properties:
                method:
                  type: string
                  enum: [class_pack, wallet, stripe_one_off, cash]
                class_pack_id:
                  type: string
                  format: uuid
                  description: Required when method=class_pack. Must be a usable pack for this class type.
                amount_cents:
                  type: integer
                  description: Optional override for stripe_one_off; defaults to class.price_cents.
      responses:
        "201":
          description: Joined successfully
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OpenClassJoin" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Capacity full or already joined
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /api/v1/joins/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Attendance id returned by `POST .../joins`.
    delete:
      tags: [Classes]
      summary: Withdraw from an open class and refund per CancellationPolicy.
      responses:
        "200":
          description: Withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:            { type: string, format: uuid }
                  status:        { type: string, enum: [cancelled] }
                  outcome:
                    type: string
                    enum: [credited, late_no_credit, late_charged]
                  recovery_pack:
                    type: object
                    nullable: true
                    properties:
                      id:   { type: string, format: uuid }
                      name: { type: string }

  /api/v1/auth/sign_up:
    post:
      tags: [Auth]
      summary: Register a new identity (alta) and optionally seed a PlayerProfile.
      description: |
        Creates a new `Identity` and returns a session pair. When the alta
        carries an `X-Academy-Id` header (or a `registration[academy_id]`
        body field), the endpoint also creates a `PlayerProfile` in that
        academy unless `Identity#auto_claim_profiles` already linked the
        new identity to a placeholder profile by email/phone match.
        Existing placeholder profiles are never overwritten.
      security: []
      parameters:
        - in: header
          name: X-Academy-Id
          required: false
          schema: { type: string, format: uuid }
          description: Academy where the PlayerProfile should be seeded when no auto-claim happens.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registration]
              properties:
                registration:
                  type: object
                  required: [email, password, first_name]
                  properties:
                    email:         { type: string, format: email }
                    password:      { type: string, format: password, minLength: 8 }
                    first_name:    { type: string }
                    last_name:     { type: string, nullable: true }
                    phone:         { type: string, nullable: true }
                    date_of_birth: { type: string, format: date, nullable: true, description: ISO-8601 (YYYY-MM-DD). Must not be in the future. }
                    gender:        { type: string, enum: [male, female, other], nullable: true }
                    academy_id:    { type: string, format: uuid, nullable: true, description: Fallback when X-Academy-Id is not sent. }
      responses:
        '201':
          description: Identity created and session issued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /api/v1/auth/sign_in:
    post:
      tags: [Auth]
      summary: Sign in with email + password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session]
              properties:
                session:
                  type: object
                  required: [email, password]
                  properties:
                    email:    { type: string, format: email }
                    password: { type: string, format: password }
      responses:
        '201':
          description: Session created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/auth/refresh:
    post:
      tags: [Auth]
      summary: Exchange a refresh token for a new access token pair.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: New session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/auth/sign_out:
    delete:
      tags: [Auth]
      summary: Revoke the current session.
      responses:
        '204': { description: Session revoked. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/auth/me:
    get:
      tags: [Auth]
      summary: Current identity + accessible academies.
      responses:
        '200':
          description: Identity and academy list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  identity:  { $ref: '#/components/schemas/Identity' }
                  academies:
                    type: array
                    items: { $ref: '#/components/schemas/AcademyMembership' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/academies:
    get:
      tags: [Academies]
      summary: List academies the current identity belongs to.
      responses:
        '200':
          description: Academy list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Academy' }

  /api/v1/academies/{id}:
    get:
      tags: [Academies]
      summary: Show one academy.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Academy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Academy' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/academies/{id}/dashboard:
    get:
      tags: [Academies]
      summary: Director analytics with KPIs, trend series, and operations breakdowns.
      description: |
        Director-only. The teaching-director ("head coach") use-case is
        served by passing `coach_id`, which narrows attendance, ARPU,
        coach productivity, series, payment methods and class fill rate
        to that membership's groups. Bookings drop from revenue when
        either filter is set since they're not coach/group-attributable.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: from
          schema: { type: string, format: date }
        - in: query
          name: to
          schema: { type: string, format: date }
        - in: query
          name: period
          schema:
            type: string
            enum: [this_week, last_week, last_15, last_month]
        - in: query
          name: coach_id
          schema: { type: string, format: uuid }
        - in: query
          name: group_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Dashboard payload.
          content:
            application/json:
              schema:
                type: object
                required: [period, filters, kpis, engagement_kpis, series, lead_funnel, payment_methods, class_fill_rate, coach_productivity]
                properties:
                  period:
                    type: object
                    properties:
                      from:          { type: string, format: date }
                      to:            { type: string, format: date }
                      previous_from: { type: string, format: date }
                      previous_to:   { type: string, format: date }
                  filters:
                    type: object
                    properties:
                      coach_id: { type: string, format: uuid, nullable: true }
                      group_id: { type: string, format: uuid, nullable: true }
                  kpis:
                    type: object
                    description: Each value is `{ current, previous, delta_pct }`.
                  engagement_kpis:
                    type: object
                    description: 'Same shape as `kpis`. Snapshot metrics carry `previous: null` and `delta_pct: null`.'
                  revenue_chart:
                    type: array
                    items:
                      type: object
                      properties:
                        month:    { type: string }
                        fees:     { type: number }
                        packs:    { type: number }
                        bookings: { type: number }
                  series:
                    type: array
                    items:
                      type: object
                      properties:
                        date:        { type: string, format: date }
                        income:      { type: number }
                        attendances: { type: integer }
                        new_users:   { type: integer }
                  lead_funnel:
                    type: object
                    properties:
                      new:             { type: integer }
                      contacted:       { type: integer }
                      trial_scheduled: { type: integer }
                      trial_done:      { type: integer }
                      enrolled:        { type: integer }
                      lost:            { type: integer }
                      total:           { type: integer }
                      conversion_pct:  { type: number }
                  payment_methods:
                    type: array
                    items:
                      type: object
                      properties:
                        method: { type: string, enum: [subscription, cash, wallet, pack, stripe_one_off] }
                        amount: { type: number }
                        pct:    { type: number }
                  class_fill_rate:
                    type: object
                    properties:
                      enrolled: { type: integer }
                      capacity: { type: integer }
                      pct:      { type: number }
                  recent_activity:
                    type: array
                    items: { type: object }
                  churn_risk:
                    type: object
                  coach_productivity:
                    type: array
                    items:
                      type: object
                      properties:
                        membership_id:   { type: string, format: uuid }
                        name:            { type: string }
                        classes_taught:  { type: integer }
                        hours_taught:    { type: number }
                        attendance_rate: { type: number, nullable: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Invalid filter (unknown coach_id/group_id) or invalid range.

  /api/v1/academies/{id}/finance_summary:
    get:
      tags: [Academies]
      summary: Revenue, outstanding fees, MRR and aging for the period.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: from
          schema: { type: string, format: date }
        - in: query
          name: to
          schema: { type: string, format: date }
        - in: query
          name: coach_id
          schema: { type: string, format: uuid }
        - in: query
          name: group_id
          schema: { type: string, format: uuid }
        - in: query
          name: compare_to
          schema:
            type: string
            enum: [previous_period, previous_month]
      responses:
        '200':
          description: Finance summary payload.
          content:
            application/json:
              schema:
                type: object
                required: [period, filters, revenue, outstanding, fees_stats, wallets, collection_series, aging, payment_method_breakdown, mrr]
                properties:
                  period:
                    type: object
                    properties:
                      from: { type: string, format: date }
                      to:   { type: string, format: date }
                  filters:
                    type: object
                    properties:
                      coach_id: { type: string, format: uuid, nullable: true }
                      group_id: { type: string, format: uuid, nullable: true }
                  revenue:
                    type: object
                    properties:
                      fees_collected: { type: number }
                      packs_sold:     { type: number }
                      bookings:       { type: number }
                      total:          { type: number }
                  outstanding:
                    type: object
                    properties:
                      pending_amount: { type: number }
                      pending_count:  { type: integer }
                      overdue_amount: { type: number }
                      overdue_count:  { type: integer }
                  fees_stats:
                    type: object
                    properties:
                      paid:  { type: integer }
                      total: { type: integer }
                  wallets:
                    type: object
                    properties:
                      total_balance: { type: number }
                  collection_series:
                    type: array
                    items:
                      type: object
                      properties:
                        date:      { type: string, format: date }
                        collected: { type: number }
                  aging:
                    type: object
                    properties:
                      bucket_0_7:     { $ref: '#/components/schemas/AgingBucket' }
                      bucket_8_30:    { $ref: '#/components/schemas/AgingBucket' }
                      bucket_31_60:   { $ref: '#/components/schemas/AgingBucket' }
                      bucket_61_plus: { $ref: '#/components/schemas/AgingBucket' }
                  payment_method_breakdown:
                    type: array
                    items:
                      type: object
                      properties:
                        method: { type: string, enum: [subscription, cash, wallet, pack, stripe_one_off] }
                        amount: { type: number }
                        pct:    { type: number }
                  mrr:      { type: number }
                  previous:
                    nullable: true
                    type: object
                    properties:
                      period:      { type: object }
                      revenue:     { type: object }
                      outstanding: { type: object }
                      mrr:         { type: number }
                  delta_pct:
                    nullable: true
                    type: object
                    properties:
                      revenue:        { type: number }
                      mrr:            { type: number }
                      pending_amount: { type: number }
                      overdue_amount: { type: number }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Invalid filter (unknown coach_id/group_id).

  /api/v1/academies/{academy_id}/classes:
    get:
      tags: [Classes]
      summary: List class records in an academy (paginated).
      parameters:
        - in: path
          name: academy_id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: Class list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  classes:
                    type: array
                    items: { $ref: '#/components/schemas/ClassRecord' }

  /api/v1/player_profiles/{id}:
    get:
      tags: [Players]
      summary: Show one player profile.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Player profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerProfileSummary' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/player_profiles/{id}/home_summary:
    get:
      tags: [Players, SessionRequests]
      summary: Player Inicio screen payload.
      description: |
        Card #9857121355 — single endpoint feeding the player home
        screen. Returns pending session requests (status=pending),
        active proposals (status=proposed; awaiting accept-and-pay),
        and the player's wallet balance so the client can decide
        between wallet vs Stripe in the accept flow.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Home summary payload.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerHomeSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/player_profiles/{player_profile_id}/recoverable_credits:
    get:
      tags: [Players]
      summary: Player's recoverable credits snapshot.
      description: |
        Returns the player's remaining `recovery_private` and `recovery_group`
        ClassPack credits plus the next expiry date per bucket. Drives the
        Flutter "Mis recuperables" banner. Reads from the consume-time
        ClassPack ledger so the count drops as soon as a credit is redeemed
        via a recovery SessionRequest.
      parameters:
        - in: path
          name: player_profile_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Recoverable credits snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [private, group]
                properties:
                  private: { $ref: '#/components/schemas/RecoverableCreditBucket' }
                  group:   { $ref: '#/components/schemas/RecoverableCreditBucket' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/player_profiles/{player_profile_id}/session_requests/recoverable_private_slots:
    get:
      tags: [Players]
      summary: Free private-class windows for a recovery_private redemption.
      description: |
        Walks each active coach's weekly `coach_availabilities` over the next
        `days` (default 14, capped at 30), subtracts existing non-cancelled
        classes and any covering `coach_unavailability`, and returns the
        resulting free intervals whose duration meets `min_minutes`. Returns
        an empty array when the player has no usable `recovery_private`
        ClassPack.
      parameters:
        - in: path
          name: player_profile_id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: days
          required: false
          schema: { type: integer, minimum: 1, maximum: 30, default: 14 }
        - in: query
          name: min_minutes
          required: false
          schema: { type: integer, minimum: 15, maximum: 240, default: 60 }
      responses:
        '200':
          description: Available private slots — possibly empty.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PrivateRecoverySlot' }

  /api/v1/session_requests/{id}/confirm:
    patch:
      tags: [SessionRequests]
      summary: Director accepts a request and proposes the class.
      description: |
        Card #9857121355 — director's "Aceptar" creates a `proposed`
        ClassRecord + `invited` Attendance. The slot is reserved on
        the agenda (the existing court-overlap validator blocks
        non-cancelled rows), but the class is NOT yet scheduled —
        the player still has to call `player_response` with
        `accepted: true` and pay before the webhook flips it to
        scheduled. Recovery requests (`private_recovery`,
        `group_recovery`) skip the proposal step and land as
        scheduled immediately.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [class]
              properties:
                class:
                  type: object
                  required: [coach_id, court_id, starts_at, ends_at, price_cents]
                  properties:
                    coach_id:    { type: string, format: uuid }
                    court_id:    { type: string, format: uuid }
                    starts_at:   { type: string, format: date-time }
                    ends_at:     { type: string, format: date-time }
                    price_cents: { type: integer, minimum: 0 }
                    payment_method:
                      type: string
                      enum: [stripe_one_off, cash, pack, wallet, subscription]
                      default: stripe_one_off
      responses:
        '200':
          description: Request transitioned to `proposed` (or `confirmed` for recovery).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionRequest' }
        '422':
          description: Missing class params or invalid transition.
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/session_requests/{id}/player_response:
    patch:
      tags: [SessionRequests]
      summary: Player accepts (with payment method) or rejects a proposal.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accepted]
              properties:
                accepted: { type: boolean }
                payment_method:
                  type: string
                  enum: [wallet, stripe]
                  description: |
                    Required when `accepted: true`. `wallet` debits the
                    player's wallet for the proposed price and
                    Attendance#mark_paid! confirms the proposal
                    synchronously. `stripe` creates a PaymentIntent
                    against the Attendance and returns its
                    `client_secret` for the Flutter Payment Sheet —
                    the webhook handler does the actual confirm once
                    Stripe reports success.
      responses:
        '200':
          description: Wallet payment landed → request `proposed`, class `scheduled`.
          content:
            application/json:
              oneOf:
                - $ref: '#/components/schemas/SessionRequest'
                - $ref: '#/components/schemas/SessionRequestWithPaymentIntent'
        '422':
          description: Insufficient balance, invalid payment method, etc.
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/v1/session_requests/{id}/proposal:
    delete:
      tags: [SessionRequests]
      summary: Cancel a proposed class.
      description: |
        Allowed for the academy director, the coach assigned to the
        proposed class, or the requesting player (or their managing
        identity). Player cancellations transition the request to
        `player_rejected`; staff cancellations to `cancelled`. Each
        path triggers its own notification job. The class itself is
        marked `cancelled` and all attendances cleared.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: Request is not proposed.

  /api/v1/session_requests/{id}/remind:
    post:
      tags: [SessionRequests]
      summary: Director/coach push the player a proposal reminder.
      description: |
        Enqueues a `ProposalReminder` notification for the player.
        Server-side dedup'd inside a 24h window so repeated taps
        don't spam the player.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '202': { description: Reminder enqueued. }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: Request is not proposed.
