openapi: 3.1.0

info:
  title: OpenIRIS Public API
  version: preview-v7
  summary: REST API for institutional integration with OpenIRIS — preview specification
  description: |
    > **⚠️ PROPOSAL — not yet implemented.** Contracts are open for review. Nothing here is deployed;
    > `https://api.openiris.io/v1/…` is the future URL, not a live service. Field names, types, and
    > scopes may change based on feedback. See the [overview page](index.html) for audience-specific
    > intro and how to give feedback.

    OpenIRIS is a resource booking / reservation management platform for research institutions.
    This API exposes OpenIRIS data to **provider institutions integrating their own systems** —
    billing exports to ERP, custom workflows, and electronic lab notebook integrations.

    ## Use cases

    - **Billing → ERP sync** — charges pulled and posted as Journal Entries into SAP, Oracle Fusion,
      NetSuite, Workday, etc. This is the priority-1 surface (see `/charges`).
    - **Custom workflows** — approval automation, dashboards, admin tooling built on the booking
      and request data.
    - **ELN / lab-software integration** — Benchling, Labguru, elabftw attaching experiment records
      to bookings and resources via `external_reference` round-trip.

    ## Conventions

    - **snake_case** JSON, ISO-8601 UTC timestamps (with `*_local` variants where fiscal-period
      matters), ISO 4217 currency codes.
    - **Cursor pagination** — collections return `{ data, meta, links }`. Navigate with `cursor_next` /
      `cursor_prev` tokens or the `links.next` / `links.prev` URLs.
    - **Rich filtering** — `filter[field][op]=value` (operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`,
      `in`, `contains`, `starts_with`, `ends_with`). Safelisted per field.
    - **Multi-key sorting** — `sort=-created_at,name` (`-` prefix = descending).
    - **Sparse fieldsets** — `fields[booking]=id,start,end`.
    - **Nested expansion** — `expand=resource,user` nests related objects inline as
      `embeds.{relation}.data`.
    - **Errors** — RFC 9457 `application/problem+json` with `code`, `trace_id`, `type` URI.
    - **Idempotency** — `Idempotency-Key` header required on `POST`/`PATCH`/non-cancel `DELETE`.
      Stored 24 h, replays return the cached response.
    - **Optimistic concurrency** — `ETag` on single-resource reads; `If-Match` required on `PATCH`.
    - **Rate limits** — per-key fixed window, default 600 req/min. Headers: `RateLimit-Limit`,
      `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` on 429.

    ## Scopes

    API keys carry one or more scopes. A request failing the scope check returns 403 with
    `code: "forbidden"`.

    | Scope | Grants |
    |---|---|
    | `billing:read` | charges, invoices, cost-centers, price-types, billing-quotes, group-orders |
    | `bookings:read` | bookings, booking-series, misuse-records, calendar feeds |
    | `bookings:write` | create/update/cancel bookings & series, booking attachments |
    | `resources:read` | resources, availability, addons, forms, statistics, search |
    | `requests:read` | requests and sub-resources |
    | `requests:write` | create/update/transition requests |
    | `users:read` | provider-scoped users, groups, organizations, projects, issues |
    | `webhooks:manage` | webhooks, events catalogue |

    ## Feedback

    Collected on the dedicated UserVoice forum at
    [openiris.uservoice.com/forums/967624-public-api-feedback](https://openiris.uservoice.com/forums/967624-public-api-feedback) — anonymous
    posts are welcome, no account needed.
  contact:
    name: OpenIRIS Public API feedback
    url: https://openiris.uservoice.com/forums/967624-public-api-feedback
    email: api-preview@openiris.io
  license:
    name: Preview — all rights reserved, no SLA
    identifier: LicenseRef-OpenIRIS-Preview
  x-logo:
    altText: OpenIRIS

servers:
  - url: https://api.openiris.io/v1
    description: Future production URL (not yet live)

security:
  - ApiKey: []

tags:
  - name: Meta
    description: API discovery, spec, and per-caller status.
  - name: Charges
    description: |
      Consolidated charges for billing / ERP export. Unifies three polymorphic DB tables (booking,
      request, product charges) behind one resource with a `source` discriminator. **Primary ERP
      integration surface** — charges map to SAP Journal Entry Post (and equivalent in Oracle,
      NetSuite, Workday).
  - name: Invoices
    description: |
      Invoice documents grouping charges for billing. Blocked pending schema additions
      (see proposal §9).
  - name: Bookings
    description: Reservations against resources — the core domain entity.
  - name: Booking series
    description: Recurring booking templates. Expand to N individual bookings.
  - name: Resources
    description: Equipment, facilities, and services that can be booked.
  - name: Requests
    description: Service requests with a lifecycle (draft → active → completed / rejected).
  - name: Calendar feeds
    description: iCalendar feeds for personal / resource / provider calendars.
  - name: Forms
    description: Custom form templates and their submissions.
  - name: Attachments
    description: File uploads attached to bookings, requests, and other entities.
  - name: Issues
    description: Equipment incident reports (separate from service requests).
  - name: Misuse records
    description: Penalty points for booking policy violations.
  - name: Users
    description: Provider-scoped directory of users.
  - name: Groups
    description: Groups / teams within organizations.
  - name: Organizations
    description: Institutions / tenants.
  - name: Projects
    description: Grants or experiments spanning bookings.
  - name: Cost centers
    description: Provider-side billing accounts.
  - name: Price types
    description: Pricing templates.
  - name: Billing quotes
    description: Price estimates.
  - name: Group orders
    description: Purchasing workflow.
  - name: Statistics
    description: Usage, utilization, heatmap aggregations.
  - name: Notifications
    description: System-wide banner messages.
  - name: Webhooks
    description: Event subscriptions — delivery, signing, activity log, retry.
  - name: Events
    description: Catalogue of event types fired over webhooks.
  - name: Search
    description: Federated full-text search across resource types.
  - name: Async tasks
    description: Polling interface for long-running operations.

x-tagGroups:
  - name: Getting started
    tags: [Meta]
  - name: Billing & ERP
    tags: [Charges, Invoices, Cost centers, Price types, Billing quotes, Group orders]
  - name: Bookings & resources
    tags: [Bookings, Booking series, Resources, Calendar feeds, Forms, Attachments]
  - name: Requests & workflows
    tags: [Requests, Issues, Misuse records]
  - name: Directory
    tags: [Users, Groups, Organizations, Projects]
  - name: Events & automation
    tags: [Webhooks, Events, Async tasks]
  - name: Reporting
    tags: [Statistics, Notifications, Search]

paths:

  # ─────────────────────────────────────────────────────────────────────────
  # META
  # ─────────────────────────────────────────────────────────────────────────

  /ping:
    get:
      tags: [Meta]
      summary: Verify the API key is accepted
      description: |
        Returns the authenticated key's resolved provider IDs and scopes. Useful as a first call
        after provisioning a key — confirms the key parses, is not revoked, and covers the expected
        providers. No scope required beyond authentication.
      operationId: getPing
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PingResponse' }
              example:
                status: ok
                provider_ids: [1, 2, 5]
                scopes: [billing:read, bookings:read, webhooks:manage]
                server_time: 2026-04-18T12:30:00Z
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /meta/scopes:
    get:
      tags: [Meta]
      summary: List all scope definitions
      operationId: getMetaScopes
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, example: billing:read }
                        description: { type: string }
                        grants: { type: array, items: { type: string }, description: Endpoints covered }

  /meta/status:
    get:
      tags: [Meta]
      summary: Per-caller status and rate-limit defaults
      operationId: getMetaStatus
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  server_time: { type: string, format: date-time }
                  key_id: { type: string }
                  provider_ids: { type: array, items: { type: integer } }
                  scopes: { type: array, items: { type: string } }
                  rate_limit:
                    type: object
                    properties:
                      per_minute: { type: integer, example: 600 }
                      remaining: { type: integer }
                      reset_seconds: { type: integer }

  /meta/schemas:
    get:
      tags: [Meta]
      summary: Discover resource schemas
      operationId: getMetaSchemas
      description: Returns JSON Schema for every public resource. Enables dynamic UI generation.
      responses:
        '200':
          description: OK

  /openapi.json:
    get:
      tags: [Meta]
      summary: OpenAPI 3.1 spec of the live API
      description: |
        **Preview note:** today serves this preview document. Once the API is implemented, this
        endpoint returns the auto-generated spec describing *live* endpoints only.
      operationId: getOpenApiSpec
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json: {}

  # ─────────────────────────────────────────────────────────────────────────
  # CHARGES — priority-1, fully specified
  # ─────────────────────────────────────────────────────────────────────────

  /charges:
    get:
      tags: [Charges]
      summary: List charges
      description: |
        The primary ERP-sync endpoint. Cursor-paginated, richly filterable by date, status, cost
        center, source. For ERP integrators: use `filter[created_at][gte]=` as the delta-sync cursor
        (see proposal §9 for `updated_at` data-quality note) and `status=invoiced` to pull charges
        ready for posting. Use `POST /charges/{id}/exports` to mark a charge as posted to your
        external system — this is how you achieve idempotency since SAP Journal Entry Post has no
        native duplicate check.
      operationId: listCharges
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Expand'
        - name: 'filter[created_at][gte]'
          in: query
          schema: { type: string, format: date-time }
        - name: 'filter[created_at][lt]'
          in: query
          schema: { type: string, format: date-time }
        - name: 'filter[status][in]'
          in: query
          schema: { type: string, example: invoiced,exported }
        - name: 'filter[source]'
          in: query
          schema: { $ref: '#/components/schemas/ChargeSource' }
        - name: 'filter[provider_id]'
          in: query
          schema: { type: string, description: 'Comma-separated provider IDs. Omit to include all the key''s providers.' }
        - name: 'filter[cost_center.provider_code]'
          in: query
          schema: { type: string }
        - name: 'filter[currency]'
          in: query
          schema: { type: string, example: EUR }
      responses:
        '200':
          description: OK
          headers:
            X-Total-Records: { $ref: '#/components/headers/XTotalRecords' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Charge' } }
              examples:
                charges-list:
                  $ref: '#/components/examples/ChargesList'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /charges/{id}:
    get:
      tags: [Charges]
      summary: Fetch a single charge
      operationId: getCharge
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: OK
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Charge' }
        '404': { $ref: '#/components/responses/NotFound' }

  /charges/{id}/line-items:
    get:
      tags: [Charges]
      summary: List line items for a consolidated charge
      operationId: getChargeLineItems
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ChargeLineItem' } }

  /charges/{id}/transitions:
    post:
      tags: [Charges]
      summary: Transition charge status
      description: Move a charge through its lifecycle (`invoiced`, `waived`, `cancelled`).
      operationId: transitionCharge
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to:
                  type: string
                  enum: [invoiced, waived, cancelled]
                reason:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Transition applied
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Charge' }
        '409':
          description: Illegal transition for current state
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetails' }

  /charges/{id}/exports:
    get:
      tags: [Charges]
      summary: List export acknowledgements for a charge
      description: |
        Every time your ERP successfully posts this charge, the integrator calls `POST` here with the
        external document ID. This endpoint returns the full history so integrators can answer
        "where did this charge land and when?"
      operationId: listChargeExports
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ChargeExport' } }
    post:
      tags: [Charges]
      summary: Record that a charge was exported to an external system
      description: |
        Idempotent. Call after posting the charge into SAP / Oracle / etc. The `external_id` you
        supply is your dedup key for retries. OpenIRIS flips the charge's `status` to `exported`.
      operationId: recordChargeExport
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_id, external_system, exported_at]
              properties:
                external_id:
                  type: string
                  maxLength: 64
                  description: Your system's document / journal entry ID. Serves as your dedup key.
                external_system:
                  type: string
                  maxLength: 32
                  example: sap-s4hana
                exported_at:
                  type: string
                  format: date-time
                notes:
                  type: string
                  maxLength: 500
      responses:
        '201':
          description: Export recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChargeExport' }

  /charges/bulk-export:
    post:
      tags: [Charges]
      summary: Bulk export charges as an async task
      description: |
        For integrators pulling large date ranges (e.g. month-end reconciliation). Returns `202`
        with a `task_id`; poll `/tasks/{task_id}` or include `completion_webhook_url` to be notified.
      operationId: bulkExportCharges
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to]
              properties:
                from: { type: string, format: date-time }
                to: { type: string, format: date-time }
                status:
                  type: array
                  items: { type: string, enum: [unbilled, invoiced, exported, waived] }
                format:
                  type: string
                  enum: [json, csv, ndjson]
                  default: json
                completion_webhook_url:
                  type: string
                  format: uri
      responses:
        '202':
          description: Accepted — poll /tasks/{id}
          headers:
            Location:
              schema: { type: string, example: /v1/tasks/tsk_abc123 }
          content:
            application/json:
              schema:
                type: object
                properties:
                  task_id: { type: string, example: tsk_abc123 }
                  status: { type: string, example: queued }

  # ─────────────────────────────────────────────────────────────────────────
  # INVOICES — priority-1, blocked pending schema
  # ─────────────────────────────────────────────────────────────────────────

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      description: |
        **Blocked pending schema** — the current DB `Invoice` entity lacks billing fields
        (`subtotal`, `tax_total`, `total`, `currency`, `issued_at`, `due_at`, `paid_at`). Shape shown
        below is the target once §9 P0 schema additions land.
      operationId: listInvoices
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: 'filter[status]'
          in: query
          schema: { type: string, enum: [draft, sent, paid, void] }
        - name: 'filter[date][gte]'
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Invoice' } }

  /invoices/{id}:
    get:
      tags: [Invoices]
      operationId: getInvoice
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
    patch:
      tags: [Invoices]
      operationId: patchInvoice
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoicePatch' }
      responses:
        '200':
          description: Updated
          content: { application/json: { schema: { $ref: '#/components/schemas/Invoice' } } }
    delete:
      tags: [Invoices]
      summary: Void an invoice (soft)
      operationId: voidInvoice
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200': { description: Voided }

  /invoices/{id}/charges:
    get:
      tags: [Invoices]
      operationId: getInvoiceCharges
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Charge' } }

  /invoices/{id}/pdf:
    get:
      tags: [Invoices]
      summary: Download invoice PDF
      operationId: getInvoicePdf
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '302':
          description: Redirect to pre-signed PDF URL
        '200':
          description: PDF stream
          content:
            application/pdf: {}

  /invoices/{id}/exports:
    post:
      tags: [Invoices]
      summary: Record invoice export to external system
      operationId: recordInvoiceExport
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [external_id, external_system]
              properties:
                external_id: { type: string }
                external_system: { type: string }
      responses:
        '201': { description: Recorded }

  # ─────────────────────────────────────────────────────────────────────────
  # BOOKINGS — priority-1, fully specified
  # ─────────────────────────────────────────────────────────────────────────

  /bookings:
    get:
      tags: [Bookings]
      summary: List bookings
      operationId: listBookings
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Expand'
        - name: 'filter[resource_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[user_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[status]'
          in: query
          schema: { $ref: '#/components/schemas/BookingStatus' }
        - name: 'filter[start][gte]'
          in: query
          schema: { type: string, format: date-time }
        - name: 'filter[start][lt]'
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Booking' } }
    post:
      tags: [Bookings]
      summary: Create a booking
      operationId: createBooking
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingCreate' }
      responses:
        '201':
          description: Created
          content: { application/json: { schema: { $ref: '#/components/schemas/Booking' } } }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /bookings/{id}:
    get:
      tags: [Bookings]
      operationId: getBooking
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Booking' } } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Bookings]
      operationId: patchBooking
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/BookingPatch' } } }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Booking' } } } }
        '412': { description: ETag mismatch }
    delete:
      tags: [Bookings]
      summary: Cancel a booking
      description: |
        Soft-cancel (sets `is_cancelled=true`, retains the record for audit/billing). **Not** a
        hard delete — the row persists.
      operationId: cancelBooking
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200': { description: Cancelled }

  /bookings/bulk:
    post:
      tags: [Bookings]
      summary: Create multiple bookings in one call (async)
      operationId: bulkCreateBookings
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bookings]
              properties:
                bookings:
                  type: array
                  maxItems: 500
                  items: { $ref: '#/components/schemas/BookingCreate' }
                completion_webhook_url: { type: string, format: uri }
      responses:
        '202':
          description: Accepted — poll /tasks/{id}
          content:
            application/json:
              schema:
                type: object
                properties:
                  task_id: { type: string }
                  status: { type: string, example: queued }

  /bookings/{id}/charges:
    get:
      tags: [Bookings]
      operationId: getBookingCharges
      security:
        - ApiKey: [billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Charge' } }

  /bookings/{id}/attachments:
    get:
      tags: [Bookings]
      operationId: listBookingAttachments
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Attachment' } }
    post:
      tags: [Bookings]
      summary: Attach a file to a booking
      operationId: addBookingAttachment
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
                description: { type: string }
      responses:
        '201': { description: Attached, content: { application/json: { schema: { $ref: '#/components/schemas/Attachment' } } } }

  /bookings/{id}/form-submission:
    get:
      tags: [Bookings]
      summary: Get the form submission attached to a booking
      description: Returns the typed JSON view — binary `Data` column is deserialized server-side.
      operationId: getBookingFormSubmission
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FormSubmission' }
        '404': { description: No form submitted for this booking }

  /bookings/{id}/audit-log:
    get:
      tags: [Bookings]
      summary: Per-booking change history
      description: Backed by `ResourceBookingLogs` (277 K rows, keyed by booking ID).
      operationId: getBookingAuditLog
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        at: { type: string, format: date-time }
                        actor_id: { type: integer }
                        action: { type: string, example: approval_changed }
                        before: { type: object, additionalProperties: true }
                        after: { type: object, additionalProperties: true }

  # ─────────────────────────────────────────────────────────────────────────
  # BOOKING SERIES
  # ─────────────────────────────────────────────────────────────────────────

  /booking-series:
    get:
      tags: [Booking series]
      operationId: listBookingSeries
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/BookingSeries' } }
    post:
      tags: [Booking series]
      summary: Create a recurring booking series (async — creates N occurrences)
      operationId: createBookingSeries
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingSeriesCreate' }
      responses:
        '202':
          description: Accepted — poll /tasks/{id} for expansion progress

  /booking-series/{id}:
    get:
      tags: [Booking series]
      operationId: getBookingSeries
      security:
        - ApiKey: [bookings:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/BookingSeries' } } }
    patch:
      tags: [Booking series]
      summary: Modify series (scope=this | following | all)
      operationId: patchBookingSeries
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                scope: { type: string, enum: [this, following, all], default: all }
                title: { type: string }
                end_until: { type: string, format: date-time }
      responses:
        '200': { description: Updated }
    delete:
      tags: [Booking series]
      summary: Cancel series (scope=this | following | all)
      operationId: cancelBookingSeries
      security:
        - ApiKey: [bookings:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: scope
          in: query
          schema: { type: string, enum: [this, following, all], default: all }
      responses:
        '200': { description: Cancelled }

  # ─────────────────────────────────────────────────────────────────────────
  # RESOURCES — priority-1, fully specified
  # ─────────────────────────────────────────────────────────────────────────

  /resources:
    get:
      tags: [Resources]
      operationId: listResources
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Sort'
        - name: 'filter[class]'
          in: query
          schema: { type: string, enum: [equipment, service, facility] }
        - name: 'filter[status]'
          in: query
          schema: { type: string, enum: [active, inactive, archived] }
        - name: 'filter[provider_id]'
          in: query
          schema: { type: string }
        - name: 'filter[q]'
          in: query
          schema:
            type: string
            description: 'Full-text search via the [Inbox] catalog'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Resource' } }

  /resources/{id}:
    get:
      tags: [Resources]
      operationId: getResource
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Resource' } } }

  /resources/{id}/availability:
    get:
      tags: [Resources]
      summary: Resource availability / busy intervals
      operationId: getResourceAvailability
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  busy:
                    type: array
                    items:
                      type: object
                      properties:
                        start: { type: string, format: date-time }
                        end: { type: string, format: date-time }
                        booking_id: { type: integer }

  /resources/{id}/scheduled-addons:
    get:
      tags: [Resources]
      summary: Add-ons that can be attached to a booking slot
      operationId: getResourceScheduledAddons
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK

  /resources/{id}/forms:
    get:
      tags: [Resources]
      summary: Form templates attached to this resource
      operationId: getResourceForms
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Form' } }

  /resources/{id}/price-types:
    get:
      tags: [Resources]
      operationId: getResourcePriceTypes
      security:
        - ApiKey: [resources:read, billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200': { description: OK }

  /resources/{id}/price-estimate:
    post:
      tags: [Resources]
      summary: Preview charges for a hypothetical booking
      description: Pure preview — no booking created, no charge persisted.
      operationId: estimateResourcePrice
      security:
        - ApiKey: [resources:read, billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id, start, end]
              properties:
                user_id: { type: integer }
                group_id: { type: integer }
                project_id: { type: integer }
                start: { type: string, format: date-time }
                end: { type: string, format: date-time }
                products:
                  type: array
                  items:
                    type: object
                    properties:
                      product_id: { type: integer }
                      quantity: { type: number }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: number }
                  currency: { type: string, example: EUR }
                  line_items: { type: array, items: { $ref: '#/components/schemas/ChargeLineItem' } }

  /resources/{id}/access-check:
    post:
      tags: [Resources]
      summary: Can this user book this resource?
      operationId: checkResourceAccess
      security:
        - ApiKey: [resources:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id, start, end]
              properties:
                user_id: { type: integer }
                start: { type: string, format: date-time }
                end: { type: string, format: date-time }
                group_id: { type: integer }
                project_id: { type: integer }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  can_book: { type: boolean }
                  reasons:
                    type: array
                    items: { type: string }
                    description: Explanations if can_book=false

  /resources/{id}/cost-center-options:
    post:
      tags: [Resources]
      summary: Which cost centers apply for a given booking context?
      operationId: getResourceCostCenterOptions
      security:
        - ApiKey: [resources:read, billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { type: integer }
                group_id: { type: integer }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  options: { type: array, items: { $ref: '#/components/schemas/CostCenter' } }
                  default_code: { type: string }

  /resources/{id}/calendar.ics:
    get:
      tags: [Resources, Calendar feeds]
      summary: iCalendar feed for a resource
      description: |
        Standard iCalendar format. URL-level token in the query string authorises access so users
        can subscribe the URL directly in Outlook/Google Calendar. Rotate via
        `POST /calendar-feeds/{id}/rotate-token`.
      operationId: getResourceCalendarFeed
      security: []
      parameters:
        - $ref: '#/components/parameters/PathId'
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            text/calendar:
              schema: { type: string }

  # ─────────────────────────────────────────────────────────────────────────
  # REQUESTS — priority-1, fully specified (sub-resources stubbed)
  # ─────────────────────────────────────────────────────────────────────────

  /requests:
    get:
      tags: [Requests]
      operationId: listRequests
      security:
        - ApiKey: [requests:read]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: 'filter[type]'
          in: query
          schema: { type: string, enum: [service, training] }
        - name: 'filter[status]'
          in: query
          schema: { type: string, enum: [draft, active, completed, rejected] }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/Request' } }
    post:
      tags: [Requests]
      operationId: createRequest
      security:
        - ApiKey: [requests:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RequestCreate' }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Request' } } } }

  /requests/{id}:
    get:
      tags: [Requests]
      operationId: getRequest
      security:
        - ApiKey: [requests:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Request' } } } }
    patch:
      tags: [Requests]
      operationId: patchRequest
      security:
        - ApiKey: [requests:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RequestPatch' }
      responses:
        '200': { description: Updated }

  /requests/{id}/transitions:
    post:
      tags: [Requests]
      summary: Transition request lifecycle
      operationId: transitionRequest
      security:
        - ApiKey: [requests:write]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, enum: [active, completed, rejected] }
                reason: { type: string }
      responses:
        '200': { description: Transitioned }

  /requests/{id}/send-to-charges:
    post:
      tags: [Requests]
      summary: Generate charges from request products / services
      operationId: sendRequestToCharges
      security:
        - ApiKey: [requests:write, billing:read]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                scope: { type: string, enum: [products, services, all], default: all }
      responses:
        '201': { description: Charges generated }

  /requests/{id}/notes:
    get:
      tags: [Requests]
      operationId: listRequestNotes
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    post:
      tags: [Requests]
      operationId: addRequestNote
      security: [{ ApiKey: [requests:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                visibility: { type: string, enum: [internal, shared] }
      responses: { '201': { description: Created } }

  /requests/{id}/history:
    get:
      tags: [Requests]
      operationId: getRequestHistory
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /requests/{id}/approvers:
    get:
      tags: [Requests]
      operationId: listRequestApprovers
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    post:
      tags: [Requests]
      operationId: addRequestApprover
      security: [{ ApiKey: [requests:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '201': { description: Created } }

  /requests/{id}/subscribers:
    get:
      tags: [Requests]
      operationId: listRequestSubscribers
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    post:
      tags: [Requests]
      operationId: addRequestSubscriber
      security: [{ ApiKey: [requests:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '201': { description: Created } }

  /requests/{id}/milestones:
    get:
      tags: [Requests]
      operationId: listRequestMilestones
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /requests/{id}/products:
    get:
      tags: [Requests]
      operationId: listRequestProducts
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /requests/{id}/messages:
    get:
      tags: [Requests]
      operationId: listRequestMessages
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    post:
      tags: [Requests]
      operationId: postRequestMessage
      security: [{ ApiKey: [requests:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '201': { description: Created } }

  /requests/{id}/attachments:
    get:
      tags: [Requests]
      operationId: listRequestAttachments
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    post:
      tags: [Requests]
      operationId: addRequestAttachment
      security: [{ ApiKey: [requests:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '201': { description: Created } }

  /requests/{id}/form-submission:
    get:
      tags: [Requests]
      operationId: getRequestFormSubmission
      security: [{ ApiKey: [requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /requests/{id}/charges:
    get:
      tags: [Requests]
      operationId: getRequestCharges
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  # ─────────────────────────────────────────────────────────────────────────
  # WEBHOOKS — priority-1, fully specified
  # ─────────────────────────────────────────────────────────────────────────

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      security:
        - ApiKey: [webhooks:manage]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      security:
        - ApiKey: [webhooks:manage]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '201':
          description: |
            Created. The `signing_secret` is returned ONCE in the response body and never again —
            store it securely. Reset via `POST /webhooks/{id}/rotate-secret`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      signing_secret: { type: string }

  /webhooks/{id}:
    get:
      tags: [Webhooks]
      operationId: getWebhook
      security: [{ ApiKey: [webhooks:manage] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } } } }
    patch:
      tags: [Webhooks]
      operationId: patchWebhook
      security: [{ ApiKey: [webhooks:manage] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/WebhookPatch' } } }
      responses: { '200': { description: Updated } }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      security: [{ ApiKey: [webhooks:manage] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '200': { description: Deleted } }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Fire a synthetic test event
      operationId: testWebhook
      security:
        - ApiKey: [webhooks:manage]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  description: Event name to simulate; defaults to webhook.test
                  example: booking.created
      responses:
        '202':
          description: Test delivery queued

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: Delivery activity log
      operationId: listWebhookDeliveries
      security:
        - ApiKey: [webhooks:manage]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionEnvelope'
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } }

  /webhooks/{id}/deliveries/{delivery_id}/redeliver:
    post:
      tags: [Webhooks]
      operationId: redeliverWebhook
      security:
        - ApiKey: [webhooks:manage]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - name: delivery_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202': { description: Redelivery queued }

  # ─────────────────────────────────────────────────────────────────────────
  # EVENTS — priority-1, fully specified
  # ─────────────────────────────────────────────────────────────────────────

  /events:
    get:
      tags: [Events]
      summary: Catalogue of event types fired over webhooks
      operationId: listEvents
      security:
        - ApiKey: [webhooks:manage]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/EventCatalogEntry' }

  /events/{name}:
    get:
      tags: [Events]
      summary: JSON Schema for a given event's payload
      operationId: getEventSchema
      security:
        - ApiKey: [webhooks:manage]
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string, example: charge.exported }
      responses:
        '200':
          description: OK
          content:
            application/json: {}

  # ─────────────────────────────────────────────────────────────────────────
  # ASYNC TASKS
  # ─────────────────────────────────────────────────────────────────────────

  /tasks/{id}:
    get:
      tags: [Async tasks]
      operationId: getTask
      security: []
      parameters: [{ $ref: '#/components/parameters/PathIdStr' }]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '404': { $ref: '#/components/responses/NotFound' }

  /tasks/{id}/cancel:
    post:
      tags: [Async tasks]
      operationId: cancelTask
      security: []
      parameters:
        - $ref: '#/components/parameters/PathIdStr'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200': { description: Cancellation requested }
        '409': { description: Task cannot be cancelled in its current state }

  # ─────────────────────────────────────────────────────────────────────────
  # SEARCH
  # ─────────────────────────────────────────────────────────────────────────

  /search:
    get:
      tags: [Search]
      summary: Federated full-text search
      operationId: search
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: types
          in: query
          schema: { type: string, example: bookings,requests,resources,users }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string }
                        id: { type: integer }
                        title: { type: string }
                        snippet: { type: string }
                        links: { $ref: '#/components/schemas/ResourceLinks' }
                  meta:
                    type: object
                    properties:
                      counts_by_type:
                        type: object
                        additionalProperties: { type: integer }

  # ─────────────────────────────────────────────────────────────────────────
  # USERS / GROUPS / ORGANIZATIONS / PROJECTS — stubbed
  # ─────────────────────────────────────────────────────────────────────────

  /users:
    get:
      tags: [Users]
      summary: List users visible to the caller
      description: |
        **Stub** — details TBD. Will return users with a relationship to the caller's providers
        (bookings, requests, memberships).
      operationId: listUsers
      security: [{ ApiKey: [users:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: OK, content: { application/json: { schema: { allOf: [{ $ref: '#/components/schemas/CollectionEnvelope' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/User' } } } }] } } } }

  /users/{id}:
    get:
      tags: [Users]
      operationId: getUser
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } } }

  /users/{id}/memberships:
    get:
      tags: [Users]
      operationId: getUserMemberships
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /groups:
    get:
      tags: [Groups]
      operationId: listGroups
      security: [{ ApiKey: [users:read] }]
      responses: { '200': { description: OK } }

  /groups/{id}:
    get:
      tags: [Groups]
      operationId: getGroup
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /groups/{id}/members:
    get:
      tags: [Groups]
      operationId: getGroupMembers
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /organizations:
    get:
      tags: [Organizations]
      operationId: listOrganizations
      security: [{ ApiKey: [users:read] }]
      responses: { '200': { description: OK } }

  /organizations/{id}:
    get:
      tags: [Organizations]
      operationId: getOrganization
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /projects:
    get:
      tags: [Projects]
      operationId: listProjects
      security: [{ ApiKey: [users:read] }]
      responses: { '200': { description: OK } }

  /projects/{id}:
    get:
      tags: [Projects]
      operationId: getProject
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  # ─────────────────────────────────────────────────────────────────────────
  # ISSUES / MISUSE — stubbed
  # ─────────────────────────────────────────────────────────────────────────

  /issues:
    get:
      tags: [Issues]
      operationId: listIssues
      security: [{ ApiKey: [users:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - name: 'filter[resource_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[status]'
          in: query
          schema: { type: string, enum: [open, in_progress, resolved, closed] }
      responses: { '200': { description: OK } }
    post:
      tags: [Issues]
      operationId: createIssue
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, resource_id]
              properties:
                title: { type: string }
                text: { type: string }
                is_urgent: { type: boolean }
                resource_id: { type: integer }
      responses: { '201': { description: Created } }

  /issues/{id}:
    get:
      tags: [Issues]
      operationId: getIssue
      security: [{ ApiKey: [users:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }
    patch:
      tags: [Issues]
      operationId: patchIssue
      security: [{ ApiKey: [users:read] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '200': { description: Updated } }

  /misuse-records:
    get:
      tags: [Misuse records]
      summary: Penalty / policy-violation records (read-only externally)
      operationId: listMisuseRecords
      security: [{ ApiKey: [bookings:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - name: 'filter[user_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[status]'
          in: query
          schema: { type: string, enum: [active, resolved, expunged] }
      responses: { '200': { description: OK } }

  /misuse-records/{id}:
    get:
      tags: [Misuse records]
      operationId: getMisuseRecord
      security: [{ ApiKey: [bookings:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  # ─────────────────────────────────────────────────────────────────────────
  # COST CENTERS / PRICES / QUOTES / ORDERS — stubbed
  # ─────────────────────────────────────────────────────────────────────────

  /cost-centers:
    get:
      tags: [Cost centers]
      operationId: listCostCenters
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/Cursor' }]
      responses: { '200': { description: OK } }

  /cost-centers/{id}:
    get:
      tags: [Cost centers]
      operationId: getCostCenter
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /price-types:
    get:
      tags: [Price types]
      operationId: listPriceTypes
      security: [{ ApiKey: [billing:read] }]
      responses: { '200': { description: OK } }

  /price-types/{id}:
    get:
      tags: [Price types]
      operationId: getPriceType
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /price-types/{id}/items:
    get:
      tags: [Price types]
      operationId: getPriceTypeItems
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /billing-quotes:
    get:
      tags: [Billing quotes]
      operationId: listBillingQuotes
      security: [{ ApiKey: [billing:read] }]
      responses: { '200': { description: OK } }
    post:
      tags: [Billing quotes]
      operationId: createBillingQuote
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      responses: { '201': { description: Created } }

  /billing-quotes/{id}:
    get:
      tags: [Billing quotes]
      operationId: getBillingQuote
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  /group-orders:
    get:
      tags: [Group orders]
      operationId: listGroupOrders
      security: [{ ApiKey: [billing:read] }]
      responses: { '200': { description: OK } }
    post:
      tags: [Group orders]
      operationId: createGroupOrder
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      responses: { '201': { description: Created } }

  /group-orders/{id}:
    get:
      tags: [Group orders]
      operationId: getGroupOrder
      security: [{ ApiKey: [billing:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK } }

  # ─────────────────────────────────────────────────────────────────────────
  # FORMS & ATTACHMENTS
  # ─────────────────────────────────────────────────────────────────────────

  /forms:
    get:
      tags: [Forms]
      operationId: listForms
      security: [{ ApiKey: [resources:read] }]
      parameters:
        - name: 'filter[resource_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[provider_id]'
          in: query
          schema: { type: integer }
      responses: { '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Form' } } } } } } } }

  /forms/{id}:
    get:
      tags: [Forms]
      operationId: getForm
      security: [{ ApiKey: [resources:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Form' } } } } }

  /form-submissions:
    get:
      tags: [Forms]
      operationId: listFormSubmissions
      security: [{ ApiKey: [resources:read] }]
      parameters:
        - name: 'filter[form_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[booking_id]'
          in: query
          schema: { type: integer }
        - name: 'filter[user_id]'
          in: query
          schema: { type: integer }
      responses: { '200': { description: OK } }

  /form-submissions/{id}:
    get:
      tags: [Forms]
      operationId: getFormSubmission
      security: [{ ApiKey: [resources:read] }]
      parameters: [{ $ref: '#/components/parameters/PathId' }]
      responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/FormSubmission' } } } } }

  /attachments:
    post:
      tags: [Attachments]
      summary: Upload (multipart; large files get a pre-signed URL)
      operationId: uploadAttachment
      security: [{ ApiKey: [bookings:write, requests:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        '201':
          description: Uploaded
          content: { application/json: { schema: { $ref: '#/components/schemas/Attachment' } } }

  /attachments/{id}:
    get:
      tags: [Attachments]
      operationId: getAttachment
      security: [{ ApiKey: [bookings:read, requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathIdStr' }]
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/Attachment' } } }

  /attachments/{id}/content:
    get:
      tags: [Attachments]
      summary: Stream the file content
      operationId: getAttachmentContent
      security: [{ ApiKey: [bookings:read, requests:read] }]
      parameters: [{ $ref: '#/components/parameters/PathIdStr' }]
      responses:
        '200':
          description: File stream
          content:
            application/octet-stream:
              schema: { type: string, format: binary }

  # ─────────────────────────────────────────────────────────────────────────
  # CALENDAR FEEDS (management)
  # ─────────────────────────────────────────────────────────────────────────

  /calendar-feeds:
    get:
      tags: [Calendar feeds]
      operationId: listCalendarFeeds
      security: [{ ApiKey: [resources:read] }]
      responses: { '200': { description: OK } }
    post:
      tags: [Calendar feeds]
      operationId: createCalendarFeed
      security: [{ ApiKey: [resources:read] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope, target_id]
              properties:
                scope: { type: string, enum: [resource, provider, user] }
                target_id: { type: integer }
      responses: { '201': { description: Created } }

  /calendar-feeds/{id}:
    delete:
      tags: [Calendar feeds]
      operationId: deleteCalendarFeed
      security: [{ ApiKey: [resources:read] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '200': { description: Deleted } }

  /calendar-feeds/{id}/rotate-token:
    post:
      tags: [Calendar feeds]
      summary: Revoke the current token and issue a new one
      operationId: rotateCalendarFeedToken
      security: [{ ApiKey: [resources:read] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses: { '200': { description: Rotated } }

  /providers/{id}/calendar.ics:
    get:
      tags: [Calendar feeds]
      summary: iCalendar feed for all resources in a provider
      operationId: getProviderCalendarFeed
      security: []
      parameters:
        - $ref: '#/components/parameters/PathId'
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            text/calendar:
              schema: { type: string }

  # ─────────────────────────────────────────────────────────────────────────
  # STATISTICS
  # ─────────────────────────────────────────────────────────────────────────

  /statistics/usage:
    get:
      tags: [Statistics]
      summary: Resource usage aggregated by user / group / resource
      description: Date ranges > 3 months return 202 with a task_id.
      operationId: getUsageStatistics
      security: [{ ApiKey: [resources:read] }]
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: group_by
          in: query
          schema: { type: string, enum: [user, group, resource, organization], default: resource }
      responses: { '200': { description: OK } }

  /statistics/utilization:
    get:
      tags: [Statistics]
      operationId: getUtilizationStatistics
      security: [{ ApiKey: [resources:read] }]
      responses: { '200': { description: OK } }

  /statistics/heatmap:
    get:
      tags: [Statistics]
      operationId: getHeatmapStatistics
      security: [{ ApiKey: [resources:read] }]
      responses: { '200': { description: OK } }

  /statistics/totals:
    get:
      tags: [Statistics]
      operationId: getTotalsStatistics
      security: [{ ApiKey: [resources:read] }]
      responses: { '200': { description: OK } }

  # ─────────────────────────────────────────────────────────────────────────
  # NOTIFICATIONS
  # ─────────────────────────────────────────────────────────────────────────

  /notifications/banner:
    get:
      tags: [Notifications]
      summary: Current system-wide banner message
      operationId: getNotificationBanner
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, nullable: true }
                  level: { type: string, enum: [info, warning, critical], nullable: true }
                  expires_at: { type: string, format: date-time, nullable: true }

# ════════════════════════════════════════════════════════════════════════════
# COMPONENTS
# ════════════════════════════════════════════════════════════════════════════

components:

  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer oi_live_{key_id}_{secret}` (or `oi_test_…` for sandbox).
        Keys carry scopes and are bound to a resolved set of providers (explicit grants +
        organization convenience grants; see proposal §2).

  parameters:
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: Opaque pagination cursor. Use `links.next` from the previous response.
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 500 }
    Sort:
      name: sort
      in: query
      schema: { type: string }
      description: |
        Comma-separated field list. Prefix `-` for descending. Example: `-created_at,name`.
        Allowed fields are endpoint-specific; unknown fields return 400.
    Fields:
      name: fields
      in: query
      schema: { type: string }
      description: |
        Sparse fieldsets, type-scoped: `fields[booking]=id,start,end&fields[charge]=amount`.
        Reduces payload size.
    Expand:
      name: expand
      in: query
      schema: { type: string }
      description: |
        Comma-separated list of relations to expand inline. Result nests expanded objects as
        `embeds.{relation}.data`. Example: `expand=resource,user`.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 64 }
      description: |
        Required on all `POST`, `PATCH`, and non-cancel `DELETE`. Responses cached 24 h per
        `(api_key_id, key)` pair. Re-posting with the same key and same body returns the cached
        response; different body returns 422.
    IfMatch:
      name: If-Match
      in: header
      required: true
      schema: { type: string }
      description: Required on `PATCH`. Value is the `ETag` from the last GET. Mismatch returns 412.
    PathId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    PathIdStr:
      name: id
      in: path
      required: true
      schema: { type: string }

  headers:
    ETag:
      schema: { type: string }
      description: Opaque version tag. Send back as `If-Match` on the next PATCH.
    XTotalRecords:
      schema: { type: integer }
      description: Total items across all pages in the current filter.
    RateLimitLimit:
      schema: { type: integer }
      description: Requests allowed in the current window.
    RateLimitRemaining:
      schema: { type: integer }
      description: Requests remaining in the current window. Only accurate on 429 in preview-v7.
    RateLimitReset:
      schema: { type: integer }
      description: Seconds until the current rate-limit window resets.

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
          example:
            type: https://docs.openiris.io/errors/401
            title: Unauthorized
            status: 401
            code: unauthorized
            detail: API key is missing, revoked, or expired.
            trace_id: 4db8e9a...
    Forbidden:
      description: Scope insufficient or resource out of tenant scope
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    NotFound:
      description: Resource does not exist or is not visible to the caller
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    Conflict:
      description: State conflict (e.g. booking overlap)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    Unprocessable:
      description: Validation error
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }
    RateLimited:
      description: Too many requests
      headers:
        Retry-After: { schema: { type: integer } }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }

  schemas:

    # ── Envelopes & common ──────────────────────────────────────────────────

    CollectionEnvelope:
      type: object
      description: Shape of every collection response.
      properties:
        meta:
          type: object
          properties:
            total: { type: integer }
            cursor_next: { type: string, nullable: true }
            cursor_prev: { type: string, nullable: true }
        links:
          type: object
          properties:
            self: { type: string, format: uri }
            next: { type: string, format: uri, nullable: true }
            prev: { type: string, format: uri, nullable: true }
            first: { type: string, format: uri }
            last: { type: string, format: uri }

    ResourceLinks:
      type: object
      properties:
        self: { type: string, format: uri }
        html: { type: string, format: uri, description: Deep link into the OpenIRIS admin UI }

    ProblemDetails:
      type: object
      description: RFC 9457 Problem Details.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        code:
          type: string
          enum: [bad_request, unauthorized, forbidden, not_found, conflict, unprocessable, rate_limited, internal_error]
        trace_id: { type: string }
        data: { type: object, additionalProperties: true }

    # ── Ping ────────────────────────────────────────────────────────────────

    PingResponse:
      type: object
      required: [status, provider_ids, scopes, server_time]
      properties:
        status: { type: string, example: ok }
        provider_ids: { type: array, items: { type: integer } }
        scopes: { type: array, items: { type: string } }
        server_time: { type: string, format: date-time }

    # ── Charges ─────────────────────────────────────────────────────────────

    ChargeSource:
      type: string
      enum: [booking, request, product]
      description: Which of the three polymorphic DB tables this charge originated from.

    CostCenter:
      type: object
      properties:
        provider_code:
          type: string
          maxLength: 10
          description: ERP-posting code — the canonical string for SAP Cost Center / Oracle Code Block.
        organization: { type: string, nullable: true }
        group: { type: string, nullable: true }
        user: { type: string, nullable: true }

    Charge:
      type: object
      required: [id, source, provider_id, user_id, currency, total, status, created_at, links]
      properties:
        id: { type: integer }
        source: { $ref: '#/components/schemas/ChargeSource' }
        source_id: { type: integer }
        booking_id: { type: integer, nullable: true }
        request_id: { type: integer, nullable: true }
        provider_id: { type: integer }
        user_id: { type: integer }
        group_id: { type: integer, nullable: true }
        project_id: { type: integer, nullable: true }
        cost_center: { $ref: '#/components/schemas/CostCenter' }
        currency: { type: string, example: EUR, description: ISO 4217 code }
        total: { type: number }
        discount_percent: { type: number, nullable: true }
        rebate_amount: { type: number, nullable: true }
        tax_amount: { type: number, nullable: true }
        tax_rate: { type: number, nullable: true }
        tax_code: { type: string, maxLength: 10, nullable: true }
        is_waived: { type: boolean }
        invoice_id: { type: integer, nullable: true }
        invoice_date: { type: string, format: date-time, nullable: true }
        invoice_date_local: { type: string, format: date-time, nullable: true, description: Local-time mirror; use this for SAP PostingDate to avoid wrong-period posting }
        status: { type: string, enum: [unbilled, invoiced, exported, waived, cancelled] }
        period_start: { type: string, format: date-time }
        period_start_local: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        period_end_local: { type: string, format: date-time }
        external_reference:
          type: string
          maxLength: 35
          nullable: true
          description: Integrator-populated, round-tripped. Sized for SAP DocumentReferenceID.
        external_account_assignment:
          type: object
          additionalProperties: true
          description: Passthrough for ERP mapping (WBS element, Oracle Code Block overrides, etc.)
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true, description: 'Currently NULL on ~75% of rows — see proposal §9 P0' }
        recalculated_at: { type: string, format: date-time, nullable: true }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    ChargeLineItem:
      type: object
      properties:
        id: { type: integer }
        date: { type: string, format: date-time }
        amount: { type: number }
        quantity: { type: number, nullable: true }
        price_item_code: { type: string, nullable: true }
        price_value: { type: number, nullable: true }
        currency: { type: string, example: EUR }
        off_hours: { type: boolean }
        is_training: { type: boolean }
        summary: { type: string, nullable: true }
        order_number: { type: integer }
        tax_amount: { type: number, nullable: true }
        tax_rate: { type: number, nullable: true }
        tax_code: { type: string, nullable: true }

    ChargeExport:
      type: object
      properties:
        id: { type: integer }
        external_id: { type: string }
        external_system: { type: string }
        exported_at: { type: string, format: date-time }
        exported_by_key_id: { type: string }
        notes: { type: string, nullable: true }

    # ── Invoices ────────────────────────────────────────────────────────────

    Invoice:
      type: object
      properties:
        id: { type: integer }
        display_id: { type: integer, nullable: true }
        provider_id: { type: integer }
        user_id: { type: integer }
        cost_center: { $ref: '#/components/schemas/CostCenter' }
        currency: { type: string, example: EUR }
        subtotal: { type: number }
        tax_total: { type: number }
        total: { type: number }
        status: { type: string, enum: [draft, sent, paid, void] }
        issued_at: { type: string, format: date-time, nullable: true }
        due_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }
        charge_count: { type: integer }
        external_reference: { type: string, maxLength: 35, nullable: true }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    InvoicePatch:
      type: object
      properties:
        status: { type: string, enum: [draft, sent, paid, void] }
        due_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }
        external_reference: { type: string, maxLength: 35, nullable: true }

    # ── Bookings ────────────────────────────────────────────────────────────

    BookingStatus:
      type: string
      enum: [confirmed, pending_approval, cancelled, completed]
      description: |
        Derived server-side (no backing column). See proposal Appendix A. Filtering by status
        translates to explicit SQL predicates — mapping is stable across versions.

    Booking:
      type: object
      required: [id, guid, start, end, status, resource_id, user_id, links]
      properties:
        id: { type: integer }
        guid: { type: string, format: uuid }
        title: { type: string, nullable: true }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        status: { $ref: '#/components/schemas/BookingStatus' }
        resource_id: { type: integer }
        user_id: { type: integer }
        group_id: { type: integer, nullable: true }
        project_id: { type: integer, nullable: true }
        request_id: { type: integer, nullable: true }
        series_id: { type: integer, nullable: true }
        operator_id: { type: integer, nullable: true }
        operator_assisted: { type: boolean }
        approval_status: { type: string, nullable: true, description: 'NULL means no approval required (not pending)' }
        cost_center: { $ref: '#/components/schemas/CostCenter' }
        comments: { type: string, nullable: true }
        location: { type: string, nullable: true }
        charge_purged: { type: boolean }
        external_reference: { type: string, maxLength: 35, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, nullable: true }
        current_caller_can_cancel: { type: boolean }
        current_caller_can_edit: { type: boolean }
        current_caller_can_approve: { type: boolean }
        current_caller_can_reject: { type: boolean }
        current_caller_can_view_charges: { type: boolean }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    BookingCreate:
      type: object
      required: [resource_id, user_id, start, end]
      properties:
        resource_id: { type: integer }
        user_id: { type: integer }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        title: { type: string }
        group_id: { type: integer }
        project_id: { type: integer }
        operator_id: { type: integer }
        comments: { type: string }
        external_reference: { type: string, maxLength: 35 }

    BookingPatch:
      type: object
      properties:
        title: { type: string }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        comments: { type: string }
        operator_id: { type: integer }

    BookingSeries:
      type: object
      properties:
        id: { type: integer }
        guid: { type: string, format: uuid }
        title: { type: string, nullable: true }
        mode: { type: string, enum: [daily, weekly, monthly] }
        interval: { type: integer, nullable: true }
        by_days: { type: array, items: { type: string } }
        ending_type: { type: string, enum: [never, count, until], nullable: true }
        end_count: { type: integer, nullable: true }
        end_until: { type: string, format: date-time, nullable: true }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        resource_id: { type: integer }
        user_id: { type: integer }
        is_cancelled: { type: boolean }
        canceled_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    BookingSeriesCreate:
      type: object
      required: [resource_id, user_id, start, end, mode]
      properties:
        resource_id: { type: integer }
        user_id: { type: integer }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        title: { type: string }
        mode: { type: string, enum: [daily, weekly, monthly] }
        interval: { type: integer }
        by_days: { type: array, items: { type: string } }
        ending_type: { type: string, enum: [never, count, until] }
        end_count: { type: integer }
        end_until: { type: string, format: date-time }
        completion_webhook_url: { type: string, format: uri }

    # ── Resources ───────────────────────────────────────────────────────────

    ResourceClass:
      type: string
      enum: [equipment, service, facility]

    Resource:
      type: object
      properties:
        id: { type: integer }
        guid: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        class: { $ref: '#/components/schemas/ResourceClass' }
        status: { type: string, enum: [active, inactive, archived] }
        provider_id: { type: integer }
        resource_type_id: { type: integer, nullable: true }
        location: { type: string, nullable: true }
        manufacturer: { type: string, nullable: true }
        model_number: { type: string, nullable: true }
        serial_number: { type: string, nullable: true }
        rfid: { type: string, nullable: true }
        concurrent_booking_enabled: { type: boolean }
        request_based_booking_enabled: { type: boolean }
        timezone: { type: string, example: Europe/Berlin, description: Resolved from the parent Provider }
        gl_account_hint: { type: string, nullable: true, description: Admin-set GL account code for ERP mapping (pass-through) }
        created_at: { type: string, format: date-time }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    # ── Requests ────────────────────────────────────────────────────────────

    Request:
      type: object
      properties:
        id: { type: integer }
        guid: { type: string, format: uuid }
        name: { type: string }
        alt_id: { type: string, nullable: true }
        type: { type: string, enum: [service, training] }
        service_id: { type: integer, description: FK to Resource }
        status: { type: string, enum: [draft, active, completed, rejected] }
        start: { type: string, format: date-time, nullable: true }
        end: { type: string, format: date-time, nullable: true }
        user_id: { type: integer }
        group_id: { type: integer, nullable: true }
        project_id: { type: integer, nullable: true }
        budget: { type: number, nullable: true }
        actual_budget: { type: number, nullable: true }
        cost_center: { $ref: '#/components/schemas/CostCenter' }
        external_reference: { type: string, maxLength: 35, nullable: true }
        created_at: { type: string, format: date-time }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    RequestCreate:
      type: object
      required: [name, service_id, user_id, type]
      properties:
        name: { type: string }
        type: { type: string, enum: [service, training] }
        service_id: { type: integer }
        user_id: { type: integer }
        group_id: { type: integer }
        project_id: { type: integer }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        budget: { type: number }
        external_reference: { type: string, maxLength: 35 }

    RequestPatch:
      type: object
      properties:
        name: { type: string }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        budget: { type: number }
        actual_budget: { type: number }
        external_reference: { type: string, maxLength: 35 }

    # ── Attachments, Forms, other ──────────────────────────────────────────

    Attachment:
      type: object
      required: [id, file_name, sha256, size_bytes, immutable, created_at]
      properties:
        id: { type: string, format: uuid }
        file_name: { type: string }
        content_type: { type: string, nullable: true }
        size_bytes: { type: integer }
        sha256: { type: string, description: SHA-256 hex of the content — integrators verify after download }
        immutable: { type: boolean, description: True once a referencing charge reaches status=exported }
        created_at: { type: string, format: date-time }
        uploaded_by_user_id: { type: integer, nullable: true }
        description: { type: string, nullable: true }
        links:
          type: object
          properties:
            self: { type: string, format: uri }
            content: { type: string, format: uri }

    FormFieldType:
      type: string
      enum: [text, number, boolean, date, select, multiselect, attachment, url, user_ref, resource_ref, booking_ref, request_ref]

    FormField:
      type: object
      properties:
        name: { type: string }
        label: { type: string }
        type: { $ref: '#/components/schemas/FormFieldType' }
        required: { type: boolean }
        validation:
          type: object
          properties:
            min: { type: number, nullable: true }
            max: { type: number, nullable: true }
            pattern: { type: string, nullable: true }
            options: { type: array, items: { type: string } }

    Form:
      type: object
      properties:
        id: { type: integer }
        provider_id: { type: integer }
        resource_id: { type: integer }
        title: { type: string }
        is_required: { type: boolean }
        comment: { type: string, nullable: true }
        fields: { type: array, items: { $ref: '#/components/schemas/FormField' } }
        version: { type: string }

    FormSubmission:
      type: object
      properties:
        id: { type: integer }
        form_id: { type: integer }
        booking_id: { type: integer, nullable: true }
        request_id: { type: integer, nullable: true }
        user_id: { type: integer }
        submission_date: { type: string, format: date-time }
        form_schema_version: { type: string }
        data:
          type: object
          description: Typed key-value pairs per the form's field schema.
          additionalProperties: true

    User:
      type: object
      properties:
        id: { type: integer }
        email: { type: string, format: email }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        orcid: { type: string, nullable: true }
        is_verified: { type: boolean }
        is_disabled: { type: boolean }
        organization_id: { type: integer }
        created_at: { type: string, format: date-time, nullable: true }
        last_login_at: { type: string, format: date-time, nullable: true }
        links: { $ref: '#/components/schemas/ResourceLinks' }

    # ── Webhooks ────────────────────────────────────────────────────────────

    Webhook:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        description: { type: string, nullable: true }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        last_delivered_at: { type: string, format: date-time, nullable: true }
        recent_failure_count: { type: integer }

    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events: { type: array, items: { type: string }, example: [charge.exported, booking.cancelled] }
        description: { type: string }
        secret_rotation: { type: string, enum: [none, monthly, quarterly], default: none }

    WebhookPatch:
      type: object
      properties:
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        description: { type: string }
        is_active: { type: boolean }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        webhook_id: { type: integer }
        event: { type: string }
        attempted_at: { type: string, format: date-time }
        response_status: { type: integer, nullable: true }
        response_body_excerpt: { type: string, nullable: true }
        succeeded: { type: boolean }
        attempt_number: { type: integer }
        next_retry_at: { type: string, format: date-time, nullable: true }

    EventCatalogEntry:
      type: object
      properties:
        name: { type: string, example: charge.exported }
        description: { type: string }
        payload_schema_url: { type: string, format: uri }

    # ── Async tasks ─────────────────────────────────────────────────────────

    Task:
      type: object
      properties:
        id: { type: string, example: tsk_abc123 }
        status: { type: string, enum: [queued, running, succeeded, failed, cancelled] }
        progress: { type: integer, minimum: 0, maximum: 100, nullable: true }
        result_url: { type: string, format: uri, nullable: true }
        error:
          type: object
          nullable: true
          properties:
            code: { type: string }
            message: { type: string }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }

  examples:
    ChargesList:
      summary: Page of charges
      value:
        data:
          - id: 5730465
            source: booking
            source_id: 4526529
            booking_id: 4526529
            provider_id: 5
            user_id: 18342
            group_id: 221
            cost_center:
              provider_code: "MDC-IMG-01"
              organization: "MDC"
              group: "Müller Lab"
            currency: EUR
            total: 36.225
            tax_amount: 5.79
            tax_rate: 0.19
            tax_code: "DE-19"
            is_waived: false
            status: invoiced
            invoice_id: 7610
            invoice_date: 2026-03-10T00:00:00Z
            invoice_date_local: 2026-03-10T01:00:00Z
            period_start: 2026-03-10T15:00:00Z
            period_end: 2026-03-10T15:30:00Z
            external_reference: null
            created_at: 2026-03-07T00:22:32Z
            updated_at: null
            links:
              self: https://api.openiris.io/v1/charges/5730465
              html: https://openiris.io/admin/charges/5730465
        meta: { total: 12430, cursor_next: eyJpIjo1NzMwNDI...", cursor_prev: null }
        links:
          self: https://api.openiris.io/v1/charges?limit=50
          next: https://api.openiris.io/v1/charges?cursor=eyJpIjo1NzMwNDI...
          prev: null
          first: https://api.openiris.io/v1/charges?limit=50
          last: https://api.openiris.io/v1/charges?cursor=eyJ...
