openapi: 3.1.0

info:
  title: officina.it API
  version: '2026-07-20'
  description: |
    API REST pubblica di officina.it: consente di leggere e scrivere i dati
    della propria officina da altri programmi (gestionali contabili, sistemi
    di flotta, strumenti di analisi).

    L'autenticazione avviene con una chiave API (`Authorization: Bearer
    ofk_live_…`) generata da Impostazioni → Accesso API. Ogni chiave porta
    permessi per risorsa e appartiene a una singola officina.

    Ogni richiesta deve indicare la revisione del contratto con l'header
    `X-API-Version`. Il percorso `/v1` identifica la versione maggiore, che
    cambia solo per modifiche non retrocompatibili; la revisione identifica
    la data del contratto. La documentazione completa è su
    https://officina.it/api.

  contact:
    name: officina.it engineering
    email: assistenza@officina.it

servers:
  - url: https://api.officina.it
    description: Production
  - url: http://localhost:8084
    description: Local development

# Default: every endpoint requires the mobile bearer token. Endpoints
# that exist precisely to issue the token (the `/auth/*` family) override
# with `security: []`.
security:
  - PublicAPIKey: []

tags:
  - name: Receipts
    description: Scontrini e corrispettivi.
  - name: Expenses
    description: Spese e categorie di spesa.
  - name: Suppliers
    description: Fornitori, ordini e documenti di trasporto.
  - name: TyreSets
    description: Deposito gomme.
  - name: ElectronicInvoicing
    description: Fatturazione elettronica (sola lettura).
  - name: Reports
    description: Report e statistiche.
  - name: Dealership
    description: "Concessionaria: stock e vendite veicoli."
  - name: Settings
    description: Impostazioni aziendali, aliquote IVA, sezionali, team.
  - name: Authentication
    description: |
      Endpoints that exchange credentials for a mobile bearer JWT.
      None of these require auth — they issue it.
  - name: Customers
    description: |
      The workshop's clients. Multi-tenant: every endpoint is scoped to
      the authenticated user's company. Cross-company access returns 404,
      never 403 (don't leak the existence of other workshops' customers).
  - name: Vehicles
    description: |
      Vehicles owned (or managed) by workshop clients. Multi-tenant:
      every endpoint is scoped to the authenticated user's company.
      Cross-company access returns 404, never 403.

      Rental setup (is_rental, rental_*) is managed through a
      separate flow; those fields are read-only on this resource.
  - name: Articles
    description: |
      Read-only catalog for the line-item editor: articles back both
      the magazzino (parts) and the listino manodopera (labor), plus
      the company VAT rates its IVA picker offers. Multi-tenant:
      scoped to the authenticated user's company.
  - name: Worksheets
    description: |
      Work orders / job cards. Multi-tenant: every endpoint is scoped
      to the authenticated user's company. Cross-company access
      returns 404, never 403. Status changes go through the
      `/v1/worksheets/{id}/transitions` RPC instead of PATCH so the
      lifecycle is explicit in the URL surface.
  - name: Quotes
    description: |
      Customer-facing job estimates. Multi-tenant: every endpoint is
      scoped to the authenticated user's company. Status flows
      waiting → accepted / rejected through the
      `/v1/quotes/{id}/transitions` RPC.
  - name: Invoices
    description: |
      Workshop invoices. Multi-tenant: every endpoint is scoped to the
      authenticated user's company. Cross-company access returns 404,
      never 403. Invoices flow through a draft → issued lifecycle;
      once issued, the `seq_number` is assigned atomically per-company
      and `issued_at` is stamped. Issued invoices remain patchable for
      a limited subset of fields (the service layer is the source of
      truth on what's mutable post-issuance).
  - name: CreditNotes
    description: |
      Credit notes are derivative documents — each one is created
      FROM an existing invoice. Multi-tenant: every endpoint is
      scoped to the authenticated user's company. Cross-company
      access returns 404, never 403. Deleting a credit note with an
      already-transmitted SDI electronic counterpart returns 409.
  - name: Checkins
    description: |
      Vehicle intake / inspection snapshots. Each checkin captures the
      vehicle's state on arrival (fuel level, kilometres, free-form
      section notes) and progresses through an `active` → `completed`
      lifecycle. Multi-tenant: every endpoint is scoped to the
      authenticated user's company. Cross-company access returns 404.

      Status transitions live behind their own verb
      (`POST /v1/checkins/{id}/transitions`) so the server can stamp
      lifecycle timestamps and run side effects centrally — flipping
      `status` through PATCH is intentionally not supported. Line-item
      mutation is out of scope for v1 PATCH and will land as a dedicated
      sub-resource later.
  - name: Calendar
    description: |
      Workshop appointments and reminders. Each entry belongs to
      exactly one company (the caller's) and is owned by the user
      who created it. Entries may reference an optional Customer,
      Vehicle, work category, and parent Calendar. The parent
      Calendar container is not exposed on this tier — only entries.
  - name: CashBook
    description: |
      Cash-flow ledger derived from payments. Each entry is a row in
      the workshop's cashbook: an income (from an invoice or worksheet)
      or an expense (from a supplier bill), carrying a cumulative
      `running_balance`. Read-only — the cashbook is a projection, not
      a first-class resource; to mutate it, create or delete the
      underlying payment/expense. Multi-tenant: every entry is scoped
      to the authenticated user's company.
  - name: Health
    description: Liveness and readiness probes.

# ───────────────────────────────────────────────────────────────
# Documentation grouping (officina.it/api)
#
# The public docs render one page per group, mirroring the app's own
# sidebar rather than the raw tag list — that is the vocabulary a workshop
# already knows. Operation counts on the docs site are COMPUTED from this
# mapping, so adding a route updates the site with no second edit.
#
# `ready: false` keeps a group visible but unlinked until its endpoint
# descriptions clear the prose floor; publishing a page with four words of
# description would be thin content on the money domain.
# ───────────────────────────────────────────────────────────────
x-doc-groups:
  - {slug: clienti,                  name: Clienti,                   tags: [Customers],           ready: true,  description: "Anagrafiche, documenti allegati e storico."}
  - {slug: veicoli,                  name: Veicoli,                   tags: [Vehicles],            ready: false, description: "Veicoli, marche e documenti allegati."}
  - {slug: schede-lavoro,            name: Schede lavoro,             tags: [Worksheets],          ready: false, description: "Schede, righe, pagamenti e stati di lavorazione."}
  - {slug: preventivi,               name: Preventivi,                tags: [Quotes],              ready: false, description: "Preventivi, righe e conversione in scheda lavoro."}
  - {slug: fatture,                  name: Fatture,                   tags: [Invoices, CreditNotes], ready: false, description: "Fatture, note di credito, pagamenti ed emissione."}
  - {slug: scontrini,                name: Scontrini,                 tags: [Receipts],            ready: false, description: "Scontrini e relativi pagamenti."}
  - {slug: accettazioni,             name: Accettazioni,              tags: [Checkins],            ready: false, description: "Accettazioni, conversioni e documenti."}
  - {slug: agenda,                   name: Agenda,                    tags: [Calendar],            ready: false, description: "Calendari e appuntamenti."}
  - {slug: magazzino,                name: Magazzino,                 tags: [Articles],            ready: false, description: "Articoli, listino manodopera e movimenti."}
  - {slug: deposito-gomme,           name: Deposito gomme,            tags: [TyreSets],            ready: false, description: "Pneumatici in deposito e passaggi di stato."}
  - {slug: fornitori,                name: Fornitori,                 tags: [Suppliers],           ready: false, description: "Fornitori, ordini e documenti di trasporto."}
  - {slug: spese,                    name: Spese,                     tags: [Expenses],            ready: false, description: "Spese, pagamenti e categorie."}
  - {slug: prima-nota,               name: Prima nota,                tags: [CashBook],            ready: false, description: "Movimenti di cassa e conti finanziari."}
  - {slug: impostazioni,             name: Impostazioni,              tags: [Settings],            ready: false, description: "Dati azienda, aliquote IVA, listini, collaboratori."}
  - {slug: fatturazione-elettronica, name: Fatturazione elettronica,  tags: [ElectronicInvoicing], ready: false, description: "Fatture elettroniche emesse e ricevute, in sola lettura."}
  - {slug: concessionaria,           name: Concessionaria,            tags: [Dealership],          ready: false, description: "Stock veicoli, vendite e report."}
  - {slug: report,                   name: Report,                    tags: [Reports],             ready: false, description: "Statistiche e contatori della tua officina."}
  - {slug: autenticazione,           name: Autenticazione,            tags: [Authentication],      ready: false, description: "Verifica della chiave e dei permessi."}

paths:
  /v1/articles:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Articles]
      summary: Search the article/labor catalog
      description: |
        The catalog behind the line-item editor's "Cerca da magazzino".
        Articles back both the magazzino (parts — `kind: item`) and the
        listino manodopera (labor — `kind: work`).

        Every whitespace-separated term in `?q=` must match the name,
        code, or comments (results are relevance-ranked while searching,
        name-sorted while browsing). Pass `?customer_id=` so
        `sale_price` reflects the customer's price-list overrides.
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - name: kind
          in: query
          description: Restrict to parts ("item") or labor ("work"); omitted = both.
          schema: { type: string, enum: [item, work] }
        - name: customer_id
          in: query
          description: Apply this customer's price-list overrides to sale_price.
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, default: 25, maximum: 50 }
        - name: starting_after
          in: query
          schema: { type: string }
      responses:
        '200':
          description: One page of catalog rows.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Article' }
                  has_more: { type: boolean }
                  next_starting_after: { type: string }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Articles]
      summary: Create an article
      operationId: articleCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/articles/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Articles]
      summary: Delete article
      operationId: articleDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    get:
      tags: [Articles]
      summary: Retrieve a article
      operationId: articleGet
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Articles]
      summary: Update article
      operationId: articlePatch
      description: |
        (the mobile edit sends every field, matching the web editor).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/articles/{id}/movements:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Articles]
      summary: Article movements
      operationId: articleMovements
      description: |
        The "Storico lavorazioni" for an article / lavorazione: the sale
        movements where the item was used (worksheet line items, order items,
        standalone receipts), newest first — same set as the web service-catalog
        detail (sale-type only). Unpaginated (a handful of rows).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ArticleMovement' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/calendar-entries:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Calendar]
      summary: List calendar entries
      description: |
        Returns calendar entries scoped to the authenticated user's
        company.

        Filter dimensions are mutually exclusive — the first match
        wins, in order: `customer_id`, `vehicle_id`, then the default
        timeframe. When no filter is supplied, returns entries between
        today (UTC) and today + 30 days across all calendars.

        Pagination: `limit` clamps at 100 and `starting_after` is
        accepted for forward-compatibility. Today the underlying
        repository does not support cursor pagination —
        `starting_after` is decoded for input validation but otherwise
        ignored, and `has_more` reflects whether the unsliced result
        set exceeded `limit`. Clients should treat the cursor as a
        stable surface that will gain semantics in a future dated
        revision.
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: vehicle_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: calendar_id
          in: query
          required: false
          description: |
            Restrict the timeframe listing to one parent calendar. Sticky per
            user: an explicit value is persisted and restored on later
            unfiltered requests (mirrors the web calendar view). Send it empty
            (`calendar_id=`) to select "all calendars"; omit it to fall back to
            the saved selection. The effective selection is echoed in
            `calendar_filter`. Ignored when filtering by `customer_id` /
            `vehicle_id`.
          schema: { type: string }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: Page of calendar entries.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/CalendarEntry' }
                      calendar_filter:
                        allOf:
                          - $ref: '#/components/schemas/CalendarFilter'
                        description: |
                          The effective calendar selection that was applied.
                          Present only on the default timeframe listing;
                          omitted when filtering by `customer_id` / `vehicle_id`.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Calendar]
      summary: Create a calendar entry
      description: |
        Creates an appointment owned by the authenticated user inside
        their company. `end_time` must be strictly after `start_time`.
        The response is the fully materialized entry (denormalized
        customer / vehicle / calendar / user fields included) so the
        client can render the new row without a follow-up GET.

        Foreign-key references in the body (`customer_id`, `vehicle_id`,
        `work_category_id`, `calendar_id`) must resolve to resources
        owned by the authenticated user's company. Unreachable refs
        return 422 `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CalendarEntryCreateRequest' }
            example:
              title: Tagliando Fiat Panda
              description: Cambio olio + filtri.
              start_time: '2026-06-12T08:00:00Z'
              end_time:   '2026-06-12T10:00:00Z'
              color: '#2563eb'
              customer_id: 11111111-1111-1111-1111-111111111111
              vehicle_id:  22222222-2222-2222-2222-222222222222
              email_reminder: true
      responses:
        '201':
          description: Calendar entry created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalendarEntry' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/calendar-entries/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Calendar entry id (UUID).
        schema: { type: string, format: uuid }
    get:
      tags: [Calendar]
      summary: Retrieve a calendar entry
      description: |
        Returns the entry if it belongs to the caller's company.
        Unknown, cross-tenant, and soft-deleted IDs all return 404 —
        never 403 — to avoid leaking the existence of resources in
        other tenants.
      responses:
        '200':
          description: Calendar entry found.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalendarEntry' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Calendar]
      summary: Update a calendar entry
      description: |
        Three-state PATCH semantics (omitted = unchanged, `null` =
        clear nullable, value = set).

        Sending `null` on a non-nullable field (`title`, `color`,
        `start_time`, `end_time`, `email_reminder`,
        `auto_reminder_enabled`) returns 422 `parameter_invalid`. When
        the patched pair of timestamps violates
        `end_time > start_time` the request is rejected with 422.

        `user_id` (ownership), `company_id`, `auto_reminder_sent_at`,
        and the audit timestamps are not patchable through this
        endpoint.

        Foreign-key references in the body (`customer_id`, `vehicle_id`,
        `work_category_id`, `calendar_id`) must resolve to resources
        owned by the authenticated user's company. Unreachable refs
        return 422 `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CalendarEntryPatchRequest' }
            example:
              title: Tagliando Fiat Panda — riprogrammato
              start_time: '2026-06-13T09:00:00Z'
              end_time:   '2026-06-13T11:00:00Z'
              description: null
      responses:
        '200':
          description: Calendar entry updated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalendarEntry' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Calendar]
      summary: Soft-delete a calendar entry
      description: |
        Stamps `deleted_at` with NOW(). The row remains in the
        database but is no longer addressable through the mobile API —
        subsequent GET/PATCH/DELETE return 404. Returns the deleted
        entry (200) so clients can render an undo banner with the
        populated `deleted_at`.
      responses:
        '200':
          description: Calendar entry soft-deleted.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CalendarEntry' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/calendars:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Calendar]
      summary: List calendars
      description: |
        Returns every calendar container in the company, unpaginated
        (workshops have a handful at most). The mobile calendar screen
        uses them for the "Calendario" filter sheet and to resolve
        entry colours; filter entries with `?calendar_id=` on
        `GET /v1/calendar-entries`.
      responses:
        '200':
          description: All calendars.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Calendar' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Calendar]
      summary: Create a calendar
      operationId: calendarCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/calendars/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Calendar]
      summary: Delete calendar
      operationId: calendarDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Calendar]
      summary: Update calendar
      operationId: calendarPatch
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/cashbook-entries:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [CashBook]
      summary: List cashbook entries
      description: |
        Returns a paginated, filtered, ordered window of cashbook
        entries for the authenticated user's company.

        The response envelope extends the standard `ListEnvelope`
        with two cashbook-specific fields:

        - `opening_balance` — the running-balance anchor at the start
          of the `from` window. Zero when no `from` is set (the
          running balance simply accumulates from zero on the first
          row).
        - `total_items` — the full filtered count, so clients can
          render "n of N" without a follow-up request.

        Page rows carry `running_balance` already computed in
        `order_direction` from `opening_balance`. Do not re-sort
        client-side without recomputing.
      parameters:
        - name: tab
          in: query
          required: false
          description: |
            Row-type filter. `tutto` (default) returns both income and
            expense; `entrate` returns income only; `uscite` returns
            expense only. The Italian tokens mirror the web app's URL
            params so deep-links transfer 1:1.
          schema:
            type: string
            enum: [tutto, entrate, uscite]
            default: tutto
        - name: financial_account_id
          in: query
          required: false
          description: Filter to a single financial account (UUID).
          schema: { type: string }
        - name: preset
          in: query
          required: false
          description: |
            Sticky date-filter preset (view key `cash_book`, shared
            with the web page). Omit preset/from/to entirely to
            restore the user's saved selection (default: last 12
            months); send them to change — and persist — it. The
            applied range is echoed as `date_filter` in the envelope.
          schema: { type: string }
        - name: from
          in: query
          required: false
          description: |
            Inclusive lower bound on `payment_date` (YYYY-MM-DD).
            When set, `opening_balance` in the response carries the
            running total of all earlier matching rows; the page
            itself starts from zero offset within the window.
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound on `payment_date` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: q
          in: query
          required: false
          description: |
            Substring match on the counterparty name (`customer_name`
            for income rows, `supplier_name` for expense rows).
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 20, max 100. Out-of-range values clamp
            silently — never error.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: |
            Opaque cursor from a prior response's next-page link.
            Pass verbatim. Omit on the first page.
          schema: { type: string }
        - name: order_direction
          in: query
          required: false
          description: |
            Sort by `payment_date`. `desc` (default) lists
            newest-first; `asc` lists oldest-first. Invalid values
            are rejected with 422 — silent clamping would silently
            flip the running balance.
          schema:
            type: string
            enum: [asc, desc]
            default: desc
      responses:
        '200':
          description: Page of cashbook entries.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CashBookListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Checkins]
      summary: List checkins
      description: |
        Returns checkins in the authenticated user's company, newest
        first. Filter by status with `?status=active|completed`; search
        by customer name with `?q=`; bound by `created_at` with
        `?from=` / `?to=` / `?preset=`; paginate with `?limit=` +
        `?starting_after=`.

        The date filter is sticky per user: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months.
        The applied range is echoed in `date_filter`. Pass `?preset=all`
        to list across all time.

        The cursor is opaque — clients MUST NOT parse it. Pass back the
        value verbatim. `has_more` is `true` when more results exist
        beyond this page.

        List responses omit `line_items` to keep payloads small. Fetch
        the detail endpoint when you need the full shape.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on the linked customer's full name.
          schema: { type: string }
        - name: status
          in: query
          required: false
          description: Filter by lifecycle state. Omit to return both.
          schema:
            type: string
            enum: [active, completed]
        - name: from
          in: query
          required: false
          description: Inclusive lower bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 20, max 100. Out-of-range values clamp
            silently to the defaults — never error.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: |
            Opaque cursor from a prior response. Pass verbatim. Omit on
            the first page.
          schema: { type: string }
      responses:
        '200':
          description: Page of checkins.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date_filter]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Checkin' }
                      date_filter: { $ref: '#/components/schemas/DateFilter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Checkins]
      summary: Create a checkin
      description: |
        Creates an intake/inspection record for an existing vehicle.

        Required body fields: `customer_id`, `vehicle_id`. Everything
        else is optional. The server assigns `id`, `company_id` (from
        the authenticated user), `created_by`, `seq_number`, timestamps,
        and an initial `status` of `"active"`.

        Line-item creation is out of scope for v1 — a created checkin
        starts with an empty `line_items` array. The response is the
        fully materialized post-create entity (same shape as
        `GET /v1/checkins/{id}`).

        Foreign-key references in the body (`customer_id`, `vehicle_id`,
        `work_category_id`) must resolve to resources owned by the
        authenticated user's company. Unreachable refs return 422
        `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckinCreateRequest' }
            example:
              customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd
              vehicle_id: 9181c4de-2a67-4b93-8f01-1e7d90a3c6b2
              title: Intake — annual service
              fuel_level: 50
              km: 124301.5
              section1: Front bumper has a scuff on the left side.
      responses:
        '201':
          description: Checkin created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Checkins]
      summary: Checkin status counts
      operationId: checkinStatusCounts
      description: |
        Returns how many accettazioni sit in each state, for the tab counters
        on the checkins list (web checkin-list StatusCount parity). Accepts
        the same filters as the list (minus status/pagination) so the numbers
        always match what the list shows:

        q                   optional substring match on customer/vehicle/plate
        from, to, preset    date filter — same sticky-per-user semantics as
        GET /v1/checkins; the applied range is echoed
        back in `date_filter`
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckinStatusCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Checkin id (UUID).
        schema: { type: string }
    get:
      tags: [Checkins]
      summary: Retrieve a checkin
      description: |
        Returns a single checkin by id, scoped to the authenticated
        user's company. Unknown ids, ids from another company, and
        soft-deleted rows all return 404 — never 403, never 200 with
        deleted_at populated.

        Detail responses include `line_items`; list responses omit them.
      responses:
        '200':
          description: Checkin found.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Checkins]
      summary: Update a checkin
      description: |
        Three-state PATCH semantics (omitted = unchanged, `null` =
        clear, value = set).

        Fields NOT patchable here: `status` (use the transitions
        endpoint), `customer_id`, `vehicle_id` (immutable),
        `seq_number`, `created_by`, timestamps, `line_items`.

        Returns the fully materialized post-update checkin.

        Foreign-key references in the body (`work_category_id`,
        `worksheet_id`, `quote_id`) must resolve to resources owned by
        the authenticated user's company. Unreachable refs return 422
        `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckinPatchRequest' }
            example:
              title: Intake — updated note
              fuel_level: 75
              section1: null
              km: 124350
      responses:
        '200':
          description: Checkin updated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Checkins]
      summary: Soft-delete a checkin
      description: |
        Stamps `deleted_at`; subsequent GET/PATCH/DELETE all 404.
        Returns the deleted resource (200) for consistency.
      responses:
        '200':
          description: Checkin soft-deleted.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/convert:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Checkin id (UUID).
        schema: { type: string }
    post:
      tags: [Checkins]
      summary: Convert into a scheda lavoro
      description: |
        Creates a worksheet carrying the checkin's customer, vehicle, km,
        title, notes, work category and delivery date — the same conversion
        the web performs (POST /hx/private/worksheets/checkins/{id}) — then
        links the checkin to it (status → `completed`, `worksheet_id` set).
        The checkin is kept. A checkin already linked to a worksheet returns
        422; when the company requires km on vehicle-linked worksheets and
        the checkin has none, 422 `parameter_missing` `/km`.

        No request body.
      responses:
        '201':
          description: The freshly created scheda lavoro (full detail shape).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/documents:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    post:
      tags: [Checkins]
      summary: Create a checkin document
      operationId: checkinDocumentCreate
      description: |
        multipart/form-data:

        file         required — the document bytes (max 25MB)
        description  optional — display label; defaults to the file name
      responses:
        '201':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckinDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/documents/{documentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Checkins]
      summary: Delete checkin document
      operationId: checkinDocumentDelete
      description: |
        CheckinDocumentDelete handles
        DELETE /v1/checkins/{id}/documents/{documentID}.
      responses:
        '200':
          description: success (the removed document)
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckinDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/documents/{documentID}/download:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Checkins]
      summary: Download checkin document
      operationId: checkinDocumentDownload
      description: |
        CheckinDocumentDownload handles
        GET /v1/checkins/{id}/documents/{documentID}/download.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/line-items/batch:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Checkin id (UUID).
        schema: { type: string }
    post:
      tags: [Checkins]
      summary: Mutate checkin line items in a batch
      description: |
        Adds, updates, and removes the checkin's line items in a single
        atomic diff, then returns the full checkin with the updated
        `line_items` array. The top-level checkin PATCH is metadata-only;
        all line-item mutation flows through this endpoint.

        Checkin line items are description-only notes, so this batch is
        narrower than the worksheet/quote one: the only field is
        `description`, and there is no `reorder` operation (the rows have
        no position). As with worksheets/quotes, `update` is applied as a
        remove+add internally, so an updated line receives a NEW id in the
        response. An id may appear in at most one of `remove`/`update`.

        All arrays are optional; an empty body is a no-op.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckinLineItemsBatchRequest' }
            example:
              add:
                - description: Scratch on rear bumper
                - description: Spare tyre missing
              update:
                - id: 4b1d8f2a-0c3e-4a6b-9d7c-1e2f3a4b5c6d
                  description: Windshield chip (driver side)
              remove:
                - 9f8e7d6c-5b4a-3c2d-1e0f-a9b8c7d6e5f4
      responses:
        '200':
          description: Batch applied. Returns the updated checkin (with `line_items`).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/quotes:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Checkin id (UUID).
        schema: { type: string }
    post:
      tags: [Checkins]
      summary: Create a preventivo from this accettazione
      description: |
        Creates a quote with the checkin's customer, vehicle, km, title,
        notes, work category and delivery date. Each checkin line item
        (inconveniente) becomes a quote line at qty 1 pz with the company's
        first enabled VAT rate — the same conversion the web performs
        (POST /hx/private/quotes/checkins/{id}). Links the checkin
        (status → `completed`, `quote_id` set). The checkin is kept.
        Already linked → 422.

        No request body.
      responses:
        '201':
          description: The freshly created preventivo (full detail shape).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/checkins/{id}/transitions:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Checkin id (UUID).
        schema: { type: string }
    post:
      tags: [Checkins]
      summary: Transition a checkin's status
      description: |
        Drives the lifecycle state machine. The body is
        `{"action": "active" | "completed"}`. Any other action value
        is a 422 `parameter_invalid`.

        Status transitions live behind their own verb so the server
        can stamp lifecycle timestamps and run side effects centrally
        — flipping `status` through PATCH is intentionally not
        supported.

        Returns the fully materialized post-transition entity.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckinTransitionRequest' }
            example:
              action: completed
      responses:
        '200':
          description: Transition applied.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkin' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/company:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Settings]
      summary: Retrieve a company
      operationId: companyGet
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/credit-notes:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [CreditNotes]
      summary: List credit notes
      description: |
        Returns credit notes in the authenticated user's company,
        newest first. Search with `?q=` (substring against customer /
        title), filter by SDI lifecycle status with `?sdi_status=`,
        and bound by `created_at` with `?from=` / `?to=` / `?preset=`.
        Paginate with cursor-based `?limit=` + `?starting_after=`.

        The date filter is sticky per user: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months.
        The applied range is echoed in `date_filter`.

        The cursor is opaque — pass it back verbatim.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on customer / title.
          schema: { type: string }
        - name: sdi_status
          in: query
          required: false
          description: Filter by SDI lifecycle status.
          schema:
            type: string
            enum: [to_send, sent, delivered, rejected]
        - name: from
          in: query
          required: false
          description: Inclusive lower bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
        - name: limit
          in: query
          required: false
          description: Page size. Default 20, max 100.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: Opaque cursor from a prior response.
          schema: { type: string }
      responses:
        '200':
          description: Page of credit notes.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date_filter]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/CreditNote' }
                      date_filter: { $ref: '#/components/schemas/DateFilter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [CreditNotes]
      summary: Create a credit note from an invoice
      description: |
        A credit note is a derivative document — every credit note
        is created FROM an existing invoice. The body carries the
        source `invoice_id` (required) plus optional `title` and
        `sezionale_id`. All other fields (totals, customer, vehicle,
        seq number, timestamps) are derived from the source invoice
        or assigned server-side.

        The source invoice MUST belong to the authenticated user's
        company. Unknown / cross-tenant invoice_ids return 404.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditNoteCreateRequest' }
            example:
              invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04
              title: Storno parziale tagliando
      responses:
        '201':
          description: Credit note created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditNote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/credit-notes/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Credit note id (UUID).
        schema: { type: string }
    get:
      tags: [CreditNotes]
      summary: Retrieve a credit note
      description: |
        Returns the credit note if it belongs to the authenticated
        user's company. Unknown ids, ids from another company, and
        soft-deleted rows all return 404 — never 403.
      responses:
        '200':
          description: Credit note found.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditNote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [CreditNotes]
      summary: Update a credit note's title
      description: |
        `title` is the only mutable field on a credit note from the
        mobile API. All other fields are either derived from the
        source invoice, immutable identity, or server-managed.

        Three-state PATCH semantics:

        - Omitted → leave the entity field unchanged.
        - Explicit JSON `null` → clear the title (SQL NULL).
        - Concrete value → set the title.

        Sending any other key returns 400 `malformed_request` —
        surfaces typos and stale schema knowledge.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditNotePatchRequest' }
            example:
              title: Storno tagliando — corretto
      responses:
        '200':
          description: Credit note updated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditNote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [CreditNotes]
      summary: Soft-delete a credit note
      description: |
        Stamps `deleted_at`; the row stays in the database, and
        subsequent GET/PATCH/DELETE all 404.

        If the credit note has an already-transmitted SDI electronic
        counterpart, deletion would orphan the electronic document
        and is refused with 409 `conflict`.

        Returns the deleted resource (200) for shape consistency
        and so the client can render an undo banner.
      responses:
        '200':
          description: Credit note soft-deleted.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditNote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Checkins ──────────────
  /v1/customers:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Customers]
      summary: List customers
      x-summary-it: Elenca i clienti
      x-description-it: |
        Restituisce i clienti della tua officina, dal più recente.
        Cerca per nome con `?q=`. Impagina con `?limit=` e `?starting_after=`.

        Il cursore è opaco: non interpretarlo, rimandalo così com'è.
        Usa `has_more` per sapere se c'è un'altra pagina, non la lunghezza di
        `data`: l'ultima pagina può essere piena.
      description: |
        Returns customers in the authenticated user's company, newest
        first. Search by name with `?q=`; paginate with cursor-based
        `?limit=` + `?starting_after=`.

        The cursor is opaque — clients MUST NOT parse it. Pass back
        the value verbatim. Today it encodes an offset; tomorrow it
        may encode a `(created_at, id)` tuple without a client-visible
        change.

        `has_more` is `true` when more results exist beyond this page.
        Drive the "load more" UI off this flag, not off `data.length`
        — the last page may still contain a full `limit` worth of
        results.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on the customer's full name.
          schema: { type: string }
        - name: sort
          in: query
          required: false
          description: |
            Sort key (default `date` = creation date). Named keys are
            mapped server-side to whitelisted columns.
          schema:
            type: string
            enum: [date, name, vehicle, spend]
        - name: direction
          in: query
          required: false
          description: Sort direction (default `desc`).
          schema: { type: string, enum: [asc, desc] }
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 20, max 100. Out-of-range values clamp
            silently to the defaults — never error.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: |
            Opaque cursor from a prior response's next-page link. Pass
            verbatim. Omit on the first page.
          schema: { type: string }
      responses:
        '200':
          description: Page of customers.
          x-description-it: "Una pagina di clienti."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Customer' }
              example:
                object: list
                data:
                  - object: customer
                    id: 8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1
                    customer_type: private
                    full_name: Mario Rossi
                    email: mario.rossi@example.com
                    phone: '+393331234567'
                    city: Milano
                has_more: true
                next_starting_after: eyJvIjoyMH0
                url: /v1/customers
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Customers]
      summary: Create a customer
      x-summary-it: Crea un cliente
      x-description-it: |
        Crea un cliente nella tua officina. L'azienda e l'autore sono sempre
        ricavati dalla chiave API: non puoi impostarli dal corpo della richiesta.

        Obbligatorio: `customer_type`. Tutto il resto è facoltativo.

        I riferimenti ad altre risorse (per esempio `price_list_id`) devono
        appartenere alla tua officina, altrimenti ricevi 422 `parameter_invalid`
        con l'indicazione del campo.
      description: |
        Creates a customer in the authenticated user's company.
        `company_id` and `created_by` are always derived from the
        bearer token — neither can be set from the body.

        Required: `customer_type`. Everything else is optional.

        Returns the fully materialized customer (re-fetched so
        computed fields like `last_vehicle_*` and `next_appointment`
        populate consistently with GET).

        Foreign-key references in the body (`price_list_id`, etc.) must
        resolve to resources owned by the authenticated user's company.
        Unreachable refs return 422 `parameter_invalid` pointing at the
        offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerCreateRequest' }
            example:
              customer_type: private
              full_name: Mario Rossi
              phone: '+39 333 1234567'
              email: mario.rossi@example.com
      responses:
        '201':
          description: Customer created.
          x-description-it: "Cliente creato."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
              example:
                object: customer
                id: 8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1
                customer_type: private
                full_name: Mario Rossi
                email: mario.rossi@example.com
                phone: '+393331234567'
                city: Milano
                created_at: '2026-08-03T14:32:11Z'
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/customers/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Customer id (UUID).
        schema: { type: string }
    get:
      tags: [Customers]
      summary: Retrieve a customer
      x-summary-it: Recupera un cliente
      x-description-it: |
        Restituisce un singolo cliente della tua officina.

        Un id sconosciuto, di un'altra officina o di un cliente eliminato
        restituisce sempre 404: mai 403, mai 200 con il record eliminato.
      description: |
        Returns a single customer by id, scoped to the authenticated
        user's company. Unknown ids, ids from another company, and
        soft-deleted rows all return 404 — never 403, never 200 with
        deleted_at populated. This prevents cross-tenant and
        post-deletion existence leaks.
      responses:
        '200':
          description: Customer found.
          x-description-it: "Cliente trovato."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
              example:
                object: customer
                id: 8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1
                customer_type: private
                full_name: Mario Rossi
                email: mario.rossi@example.com
                phone: '+393331234567'
                city: Milano
                created_at: '2026-08-03T14:32:11Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Customers]
      summary: Update a customer
      x-summary-it: Aggiorna un cliente
      x-description-it: |
        Aggiorna solo i campi presenti nel corpo della richiesta: quelli omessi
        restano invariati. Per svuotare un campo inviagli esplicitamente `null`.
      description: |
        Partial update following the design philosophy:

        - **Omitted JSON keys** leave the corresponding entity fields
          untouched. The mobile UI can send only the keys it actually
          changed.
        - **Explicit JSON `null`** clears the field (sets the column
          to SQL NULL in the database). Use this to wipe an optional
          field — there is no `?clear=phone` query param.
        - **Concrete values** overwrite the entity field with the value.

        `customer_type` is NOT NULL in the schema; sending it as
        `null` is a 422 `parameter_invalid` error.

        Returns the fully materialized post-update customer.

        Foreign-key references in the body (`price_list_id`, etc.) must
        resolve to resources owned by the authenticated user's company.
        Unreachable refs return 422 `parameter_invalid` pointing at the
        offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerPatchRequest' }
            example:
              full_name: Mario Rossi
              phone: null
              email: mario.rossi@example.com
      responses:
        '200':
          description: Customer updated. Returns the full post-update entity.
          x-description-it: "Cliente aggiornato. Restituisce la scheda completa."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
              example:
                object: customer
                id: 8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1
                customer_type: private
                full_name: Mario Rossi
                email: mario.rossi@example.com
                phone: '+393331234567'
                city: Milano
                created_at: '2026-08-03T14:32:11Z'
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Customers]
      summary: Soft-delete a customer
      x-summary-it: Elimina un cliente
      x-description-it: |
        Eliminazione logica: il cliente sparisce dagli elenchi ma i documenti
        già emessi restano validi e collegati.
      description: |
        Marks the customer as deleted by stamping `deleted_at` with the
        server clock. The row stays in the database; subsequent
        GET/PATCH/DELETE all 404.

        Returns the deleted resource (200) rather than 204 No Content so
        the response shape is consistent across the API (always a typed
        resource) and the mobile UI can render an undo banner with the
        timestamp.

        Restoration is an admin-only operation; not exposed via this API.
      responses:
        '200':
          description: Customer soft-deleted. `deleted_at` is populated.
          x-description-it: "Cliente eliminato. Il campo «deleted_at» viene valorizzato."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
              example:
                object: customer
                id: 8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1
                customer_type: private
                full_name: Mario Rossi
                email: mario.rossi@example.com
                phone: '+393331234567'
                city: Milano
                created_at: '2026-08-03T14:32:11Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/customers/{id}/documents:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Customer id (UUID).
        schema: { type: string }
    post:
      tags: [Customers]
      summary: Upload a document
      x-summary-it: Carica un documento
      x-description-it: |
        Allega un file alla scheda del cliente.
        La richiesta è `multipart/form-data`.
      description: |
        Attaches a file to the customer — the one multipart write on this
        API (`Content-Type: multipart/form-data`). Storage layout matches
        the web upload, so documents from either surface are identical.
        Max 25MB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                description:
                  type: string
                  description: Display label; defaults to the file name.
      responses:
        '201':
          description: The stored document (url = its download route).
          x-description-it: "Il documento salvato («url» è la rotta di download)."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerDocument' }
              example:
                object: customer_document
                id: 3b91ee0c-19b4-4f0a-9c2e-7d51a0c88e14
                file_name: libretto.pdf
                content_type: application/pdf
                size: 184320
                url: /v1/customers/8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1/documents/3b91ee0c-19b4-4f0a-9c2e-7d51a0c88e14/download
                created_at: '2026-08-03T14:41:02Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/customers/{id}/documents/{documentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Customers]
      summary: Delete customer document
      x-summary-it: Elimina un documento
      x-description-it: |
        Rimuove il file allegato alla scheda del cliente.
      operationId: customerDocumentDelete
      description: |
        CustomerDocumentDelete handles
        DELETE /v1/customers/{id}/documents/{documentID}.
      responses:
        '200':
          description: success (the removed document)
          x-description-it: "Documento eliminato."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CustomerDocument' }
              example:
                object: customer_document
                id: 3b91ee0c-19b4-4f0a-9c2e-7d51a0c88e14
                file_name: libretto.pdf
                content_type: application/pdf
                size: 184320
                url: /v1/customers/8f173fd2-6fa4-46e5-a8e3-23dfff31a0a1/documents/3b91ee0c-19b4-4f0a-9c2e-7d51a0c88e14/download
                created_at: '2026-08-03T14:41:02Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/customers/{id}/documents/{documentID}/download:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Customer id (UUID).
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Document id (UUID).
        schema: { type: string }
    get:
      tags: [Customers]
      summary: Download a document
      x-summary-it: Scarica un documento
      x-description-it: |
        Restituisce un link temporaneo per scaricare il file allegato.
      description: |
        Streams the document bytes with the stored `Content-Type` and an
        `attachment` disposition — the target of every
        `customer_document.url`. Bearer-authenticated like every other
        route; clients save the bytes and hand them to the OS viewer.
      responses:
        '200':
          description: The file bytes.
          x-description-it: "Il contenuto del file."
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Vehicles ──────────────
  /v1/dealership-report/{widget}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: widget
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Reports]
      summary: Dealership report widget
      operationId: dealershipReportWidget
      description: |
        the Situazione Concessionaria dashboard, mirroring the officina
        report surface (one route per widget, progressive loading, sticky
        date filter under the "dealership-report" view key).

        Widget keys:

        kpis                    the web KPI row bundled: vehicles-sold,
        total-revenue, total-margin, average-days,
        average-profit-per-vehicle,
        immobilized-capital, loss-sales-percentage,
        average-markup-percentage — each with the
        previous-period delta
        sales-chart             revenue + margin series per period bucket
        top-profitable-sales    ranked best sales by profit
        bottom-profitable-sales ranked worst sales by profit
        top-giacenza-sales      fastest vehicles (lowest days in stock)
        bottom-giacenza-sales   slowest vehicles (highest days in stock)

        Gate: dealership.read + reports.read, like the web dashboard (the
        module flag gates the menu client-side).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/delivery-notes:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Suppliers]
      summary: List delivery notes
      operationId: deliveryNoteList
      description: |
        trasporto" (DDT) list.



        The date filter is sticky per user under the shared "delivery_notes" view
        key (same as the web list) and defaults to the last 12 months; the applied
        range is echoed in `date_filter`. Scoped to the authenticated user's
        company and gated on the articles.read permission (web parity — DDT lives
        under the inventory module).
      parameters:
        - name: q
          in: query
          required: false
          description: |
            optional substring match on the supplier name (web parity)
          schema: { type: string }
        - name: status
          in: query
          required: false
          description: |
            "draft" | "issued" — the tab filter; omitted returns
          schema: { type: string }
        - name: both
          in: query
          required: false
          description: |
            ("Tutti" tab)
          schema: { type: string }
        - name: supplier_id
          in: query
          required: false
          description: |
            narrow to a single supplier
          schema: { type: string }
        - name: sort,
          in: query
          required: false
          description: |
            direction named sort key (date/number/supplier/total) +
            asc|desc;
          schema: { type: string }
        - name: default
          in: query
          required: false
          description: |
            date desc
          schema: { type: string }
        - name: from,
          in: query
          required: false
          description: |
            to YYYY-MM-DD date range bounds (on
            delivery_notes.created_at)
          schema: { type: string }
        - name: preset
          in: query
          required: false
          description: |
            date preset: rel30/rel90/rel365, year/Q1-Q4/month/custom,
            all
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: |
            page size (default 20, max 100)
          schema: { type: string }
        - name: starting_after
          in: query
          required: false
          description: |
            opaque cursor from a previous response
          schema: { type: string }
      responses:
        '200':
          description: + date_filter    success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/DeliveryNote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/delivery-notes/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Suppliers]
      summary: Delivery note status counts
      operationId: deliveryNoteStatusCounts
      description: |
        Bozza / Emessi tab counters. Counts respect the sticky date range, the
        free-text search and the selected supplier (web parity). The applied range
        is echoed in date_filter.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeliveryNoteStatusCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/electronic-invoices:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [ElectronicInvoicing]
      summary: List electronic invoices
      operationId: electronicInvoiceList
      description: |
        (attive) transmissions, newest first.

        document_type (TD01/TD04/scontrino, ""=all), from/to/preset (sticky per
        view, default last 12 months, echoed in date_filter).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/electronic-invoices-incoming:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [ElectronicInvoicing]
      summary: List electronic invoice incomings
      operationId: electronicInvoiceIncomingList
      description: |
        — the passive supplier invoices synced from ACube.

        FatturaPA code, ""=all), supplier_id, from/to/preset (sticky per view).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/electronic-invoices-incoming/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [ElectronicInvoicing]
      summary: Electronic invoice incoming status counts
      operationId: electronicInvoiceIncomingStatusCounts
      description: |
        /v1/electronic-invoices-incoming/status-counts — the Passive tab counters
        (Ricevute / In quarantena), same filters as the list minus status.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/electronic-invoices/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [ElectronicInvoicing]
      summary: Electronic invoice status counts
      operationId: electronicInvoiceStatusCounts
      description: |
        /v1/electronic-invoices/status-counts — the Attive tab counters. Uses the
        same filters as the list minus status (so the counts stay stable while
        switching tabs, web parity).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/expense-categories:
    parameters: [{ $ref: '#/components/parameters/APIVersion' }]
    get:
      tags: [Expenses]
      summary: List expense categories (unpaginated)
      responses:
        '200': { description: Categories. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/expenses:
    parameters: [{ $ref: '#/components/parameters/APIVersion' }]
    get:
      tags: [Expenses]
      summary: List expenses
      responses:
        '200': { description: Expenses + date_filter. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Expenses]
      summary: Create an expense (totals-first)
      responses:
        '201': { description: Expense. }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/expenses/status-counts:
    parameters: [{ $ref: '#/components/parameters/APIVersion' }]
    get:
      tags: [Expenses]
      summary: Tab counters (to_pay / expired / paid)
      responses:
        '200': { description: Counts + date_filter. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/expenses/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Expenses]
      summary: Retrieve an expense (line items + installments)
      responses:
        '200': { description: Expense. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Expenses]
      summary: Update header fields and totals
      responses:
        '200': { description: Updated expense. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Expenses]
      summary: Delete an expense
      responses:
        '200': { description: The removed expense. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/expenses/{id}/payments:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Expenses]
      summary: List expense payments
      operationId: expensePaymentList
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Expenses]
      summary: Create an expense payment
      operationId: expensePaymentCreate
      responses:
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
  /v1/expenses/{id}/payments/{paymentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: paymentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Expenses]
      summary: Delete expense payment
      operationId: expensePaymentDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/financial-accounts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [CashBook]
      summary: List the company's financial accounts
      description: |
        The conti (Banca, Cassa, …) — the options of the prima nota
        Conto filter, mirroring the web cashbook's account dropdown.
        Unpaginated; read-only (accounts are managed in the web
        settings).
      responses:
        '200':
          description: The company's accounts.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items:
                      type: object
                      required: [object, id, type, name]
                      properties:
                        object: { type: string, const: financial_account }
                        id:     { type: string }
                        type:   { type: string, example: bank }
                        name:   { type: string, example: Banca }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Health ──────────────
    post:
      tags: [CashBook]
      summary: Create a financial account
      operationId: financialAccountCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/financial-accounts/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [CashBook]
      summary: Delete financial account
      operationId: financialAccountDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [CashBook]
      summary: Update financial account
      operationId: financialAccountPatch
      description: |
        (rename only — the type is fixed at creation, web parity).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Invoices]
      summary: List invoices
      description: |
        Returns invoices in the authenticated user's company, newest
        first. Filter by name (`?q=`), payment state
        (`?payment_filter=pending|settled`), fiscal status
        (`?fiscal_status=draft|issued`), or date range
        (`?from=`/`?to=`/`?preset=`). Paginate with cursor-based
        `?limit=` + `?starting_after=`.

        The date filter is sticky per user: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months.
        The applied range is echoed in `date_filter`.

        The cursor is opaque — clients MUST NOT parse it. Pass back
        the value verbatim. `has_more` drives the "load more" UI;
        a full page may still be the last page.

        List rows OMIT `line_items` to keep payloads small. Fetch
        the detail endpoint for the full body.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on invoice display fields.
          schema: { type: string }
        - name: payment_filter
          in: query
          required: false
          schema: { type: string, enum: [pending, settled] }
        - name: fiscal_status
          in: query
          required: false
          schema: { type: string, enum: [draft, issued] }
        - name: sort
          in: query
          required: false
          description: |
            Sort key (default `date` = emission date, i.e. `issued_at`
            falling back to `created_at`). Named keys are mapped
            server-side to whitelisted columns.
          schema:
            type: string
            enum: [date, number, customer, vehicle, total, outstanding]
        - name: direction
          in: query
          required: false
          description: Sort direction (default `desc`).
          schema: { type: string, enum: [asc, desc] }
        - name: from
          in: query
          required: false
          description: Inclusive lower bound (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound (YYYY-MM-DD).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: Opaque cursor from a prior response.
          schema: { type: string }
      responses:
        '200':
          description: Page of invoices (line_items omitted).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date_filter]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Invoice' }
                      date_filter: { $ref: '#/components/schemas/DateFilter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Invoices]
      summary: Create a draft invoice from a worksheet
      description: |
        Snapshots the referenced worksheet's totals and line items
        into a new draft invoice. The worksheet MUST belong to the
        authenticated user's company; cross-tenant requests return
        404 (same as direct invoice access).

        `payment_due_at` is optional. When omitted the server
        computes today + the company's default payment term (e.g.
        `30_days`), matching the web flow.

        The response includes the materialized `line_items` array.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceCreateRequest' }
            example:
              worksheet_id: wks_91247
              payment_method: bank_transfer
              payment_due_at: '2026-06-30T00:00:00Z'
      responses:
        '201':
          description: Invoice created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/payment-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Invoices]
      summary: Count invoices per payment state
      description: |
        Returns how many invoices are pending payment vs fully settled,
        for the tab counters above the mobile list. Accepts the same
        filters as `GET /v1/invoices` minus `payment_filter` and
        pagination (`q`, `from`/`to`/`preset` with the same sticky
        date-filter semantics), so the numbers always match what the
        list shows. The applied range is echoed in `date_filter`.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on invoice display fields.
          schema: { type: string }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
      responses:
        '200':
          description: Counts per payment state.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoicePaymentCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Invoice id (UUID).
        schema: { type: string }
    get:
      tags: [Invoices]
      summary: Retrieve an invoice
      description: |
        Returns a single invoice by id, scoped to the authenticated
        user's company. Unknown ids, ids from another company, and
        soft-deleted rows all return 404 — never 403, never 200 with
        `deleted_at` populated.

        Includes the nested `line_items` array.
      responses:
        '200':
          description: Invoice found.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Invoices]
      summary: Update an invoice
      description: |
        Partial update following the design philosophy:

        - Omitted JSON keys leave the corresponding entity fields
          untouched.
        - Explicit JSON `null` clears the field (SQL NULL) where the
          column is nullable.
        - Concrete values overwrite the entity field.

        Non-nullable columns (`payment_method`, `payment_condition`,
        `payment_due_at`, `vat_exigibility`) reject explicit `null`
        with 422 `parameter_invalid`.

        Fields NOT patchable through this endpoint: `id`,
        `seq_number`, `status`, `customer_id`, `vehicle_id`,
        `worksheet_id`, all totals, all timestamps except
        `payment_due_at`. Line items are not mutable via PATCH.
        Use POST `/v1/invoices/{id}/issue` to transition a draft.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoicePatchRequest' }
            example:
              title: Tagliando completo
              comments: null
              payment_method: card
              payment_due_at: '2026-07-15T00:00:00Z'
      responses:
        '200':
          description: Invoice updated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Invoices]
      summary: Soft-delete an invoice
      description: |
        Stamps `deleted_at`; the row stays in the database, and
        subsequent GET/PATCH/DELETE/issue all 404. Returns the
        deleted resource (200) for consistency with the rest of the
        API and so the client can render an undo banner.
      responses:
        '200':
          description: Invoice soft-deleted.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/{id}/convert-to-scontrino:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Invoice id (UUID).
        schema: { type: string }
    post:
      tags: [Invoices]
      summary: Convert a draft fattura into a scontrino
      description: |
        Swaps a non-emessa, worksheet-backed draft invoice for a draft
        scontrino on the same scheda lavoro — the same conversion the web
        performs (POST /hx/private/invoices/{id}/convert-to-scontrino).

        Payments move onto the new receipt; the source invoice is
        soft-deleted; `worksheets.fiscal_document_type` flips to scontrino.
        Line items are not copied (both documents project the worksheet).

        No request body. Server re-validates every convertibility guard:
        draft only, worksheet-backed, no blocking e-invoice (past draft),
        no linked nota di credito, no ritenuta d'acconto.

        Returns 422 when any guard fails. The invoice is gone after a
        successful conversion — navigate to the returned receipt.
      responses:
        '201':
          description: The freshly created draft scontrino (full detail shape).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Receipt' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/{id}/issue:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Invoice id (UUID).
        schema: { type: string }
    post:
      tags: [Invoices]
      summary: Issue a draft invoice
      description: |
        Transitions an invoice from draft to issued, stamping
        `issued_at` and assigning the next `seq_number` for the
        company atomically.

        The body is optional. When `issued_at` is omitted the server
        uses its own clock. Client-supplied back-dates that would
        violate the monotonic per-company sequence return 409.
        Issuing an already-issued invoice also returns 409 (issuance
        is not idempotent — seq_number would be wasted).
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceIssueRequest' }
            example:
              issued_at: '2026-05-29T12:00:00Z'
      responses:
        '200':
          description: Invoice issued. `status` is now "issued".
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/{id}/payments:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Invoices]
      summary: List an invoice's payments
      description: |
        Payments registered against the invoice (acconti/saldi), the
        same surface as the worksheet-scoped endpoints without the
        worksheet indirection. Unpaginated.
      responses:
        '200':
          description: Payments, oldest first.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Payment' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Invoices]
      summary: Register a payment on an invoice
      description: |
        Mirrors the web invoice dialog: the payment attaches to the
        invoice and the invoice paid-state is refreshed.
        `payment_method` falls back to the customer's preferred method,
        then the company default, when missing or invalid. The optional
        `financial_account_id` (conto di saldo) must belong to the
        company.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCreateRequest' }
            example:
              amount: 150.0
              payment_method: MP01
              payment_date: '2026-07-16'
      responses:
        '201':
          description: Payment registered.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/invoices/{id}/payments/{paymentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: paymentID
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Invoices]
      summary: Remove a payment from an invoice
      description: |
        Hard-deletes the payment (payments have no soft-delete) and
        refreshes the invoice paid-state. The payment must belong to
        the invoice — otherwise 404.
      responses:
        '200':
          description: The removed payment.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/manufacturers:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Vehicles]
      summary: List vehicle manufacturers
      description: |
        Returns every vehicle manufacturer available to the
        authenticated user's company — the global catalog plus any
        company-specific additions. Referenced by
        `Vehicle.manufacturer_id` on vehicle creation.

        Intentionally unpaginated: the set is small and changes
        rarely, so clients fetch once and filter locally in the
        picker UI. `has_more` is always `false`.
      responses:
        '200':
          description: Full manufacturer catalog.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Manufacturer' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Vehicles]
      summary: Replace manufacturer
      operationId: manufacturerSet
      description: |
        company's enabled brand set; new_names creates custom brands (web
        "new-brand" parity) and enables them in the same call.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/manufacturers/catalog:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Vehicles]
      summary: Manufacturer catalog
      operationId: manufacturerCatalog
      description: |
        brand catalog with the company's enabled marks and usage counts.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/orders:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Suppliers]
      summary: List orders
      operationId: orderList
      description: |
        The date filter is sticky per user under the shared "orders" view key
        (same as the web list) and defaults to the last 12 months; the applied
        range is echoed back in `date_filter`. Scoped to the authenticated
        user's company and gated on the orders.read permission.
      parameters:
        - name: q
          in: query
          required: false
          description: |
            optional substring match on supplier name / supplier
          schema: { type: string }
        - name: invoice
          in: query
          required: false
          description: |
            reference (web parity)
          schema: { type: string }
        - name: status
          in: query
          required: false
          description: |
            "pending" | "delivery" | "received" — the tab filter;
          schema: { type: string }
        - name: omitted
          in: query
          required: false
          description: |
            returns every status ("Tutti" tab)
          schema: { type: string }
        - name: supplier_id
          in: query
          required: false
          description: |
            narrow to a single supplier
          schema: { type: string }
        - name: sort,
          in: query
          required: false
          description: |
            direction named sort key
            (date/number/supplier/invoice/total) +
          schema: { type: string }
        - name: asc|desc;
          in: query
          required: false
          description: |
            default date desc
          schema: { type: string }
        - name: from,
          in: query
          required: false
          description: |
            to YYYY-MM-DD date range bounds (on orders.created_at)
          schema: { type: string }
        - name: preset
          in: query
          required: false
          description: |
            date preset: rel30/rel90/rel365, year/Q1-Q4/month/custom,
            all
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: |
            page size (default 20, max 100)
          schema: { type: string }
        - name: starting_after
          in: query
          required: false
          description: |
            opaque cursor from a previous response
          schema: { type: string }
      responses:
        '200':
          description: + date_filter          success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/orders/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Suppliers]
      summary: Order status counts
      operationId: orderStatusCounts
      description: |
        tab counters for the "Arrivi Merce" list. Counts respect the sticky date
        range and the free-text search (supplier narrowing is not applied to the
        counts, web parity). The applied range is echoed in date_filter.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderStatusCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/price-lists:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Settings]
      summary: List price lists
      operationId: priceListList
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Settings]
      summary: Create a price list
      operationId: priceListCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/price-lists/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Settings]
      summary: Delete price list
      operationId: priceListDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Settings]
      summary: Update price list
      operationId: priceListPatch
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/quotes:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Quotes]
      summary: List quotes
      description: |
        Returns quotes in the authenticated user's company, newest
        first. Filter by status with `?status=`, search by customer
        or title with `?q=`, and bound the result by `created_at`
        with `?from=` / `?to=` / `?preset=`. Paginate with cursor-based
        `?limit=` + `?starting_after=`.

        The date filter is sticky per user: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months.
        The applied range is echoed in `date_filter`.

        List rows omit `line_items` to keep the payload small —
        fetch the detail endpoint when you need the full body.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match against customer / title.
          schema: { type: string }
        - name: status
          in: query
          required: false
          description: Filter to a single lifecycle state.
          schema:
            type: string
            enum: [waiting, accepted, rejected]
        - name: sort
          in: query
          required: false
          description: |
            Sort key (default `date` = creation date). Named keys are
            mapped server-side to whitelisted columns.
          schema:
            type: string
            enum: [date, number, customer, vehicle, total]
        - name: direction
          in: query
          required: false
          description: Sort direction (default `desc`).
          schema: { type: string, enum: [asc, desc] }
        - name: from
          in: query
          required: false
          description: Inclusive lower bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
        - name: limit
          in: query
          required: false
          description: Page size. Default 20, max 100.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: Opaque cursor from a prior response. Omit on the first page.
          schema: { type: string }
      responses:
        '200':
          description: Page of quotes (without line items).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date_filter]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Quote' }
                      date_filter: { $ref: '#/components/schemas/DateFilter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Quotes]
      summary: Create a quote
      description: |
        Creates a new quote owned by the authenticated user's
        company. Server-managed fields (`id`, `company_id`,
        `created_by`, `seq_number`, `status`, all timestamps,
        totals) are filled in automatically. Status starts as
        `waiting`. Line items are not accepted at creation time.

        Foreign-key references in the body (`customer_id`, `vehicle_id`,
        `work_category_id`) must resolve to resources owned by the
        authenticated user's company. Unreachable refs return 422
        `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteCreateRequest' }
            example:
              customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd
              vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55
              title: Preventivo tagliando
              km: 61240
      responses:
        '201':
          description: Quote created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/quotes/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Quotes]
      summary: Count quotes per status
      description: |
        Returns how many quotes sit in each status, for the tab counters
        above the mobile list. Accepts the same filters as
        `GET /v1/quotes` minus `status` and pagination (`q`,
        `from`/`to`/`preset` with the same sticky date-filter
        semantics), so the numbers always match what the list shows.
        The applied range is echoed in `date_filter`.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match against customer or vehicle name.
          schema: { type: string }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
      responses:
        '200':
          description: Counts per status.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteStatusCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/quotes/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Quote id (UUID).
        schema: { type: string }
    get:
      tags: [Quotes]
      summary: Retrieve a quote
      description: |
        Returns a single quote (with embedded line items) by id,
        scoped to the authenticated user's company. Unknown ids,
        ids from another company, and soft-deleted rows all return
        404 — never 403.
      responses:
        '200':
          description: Quote found. Includes `line_items`.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Quotes]
      summary: Update quote metadata
      description: |
        Three-state PATCH semantics (omitted = unchanged, `null` =
        clear, value = set). Status transitions go through
        `POST /v1/quotes/{id}/transitions`, not PATCH.

        Fields not listed in `QuotePatchRequest` are intentionally
        NOT patchable: `id`, `company_id`, `created_by`,
        `seq_number`, `status`, `customer_id`, `vehicle_id`, all
        totals, all timestamps, `worksheet_id`, `line_items`.

        Foreign-key references in the body (`work_category_id`) must
        resolve to resources owned by the authenticated user's company.
        Unreachable refs return 422 `parameter_invalid` pointing at the
        offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuotePatchRequest' }
            example:
              title: Preventivo aggiornato
              comments: null
      responses:
        '200':
          description: Quote updated. Returns the full post-update entity.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Quotes]
      summary: Soft-delete a quote
      description: |
        Marks the quote as deleted by stamping `deleted_at` with the
        server clock. Returns the deleted resource (200) for shape
        consistency.
      responses:
        '200':
          description: Quote soft-deleted. `deleted_at` is populated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/quotes/{id}/convert:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Quote id (UUID).
        schema: { type: string }
    post:
      tags: [Quotes]
      summary: Convert into a scheda lavoro
      description: |
        Creates a worksheet carrying the quote's customer, vehicle, km,
        title, notes, line items and totals — the same conversion the web
        performs — then links the quote to it (which also marks the quote
        `accepted`). The quote is kept. A quote already linked to a
        worksheet returns 422; when the company requires km on
        vehicle-linked worksheets and the quote has none, 422
        `parameter_missing` `/km`.

        No request body.
      responses:
        '201':
          description: The freshly created scheda lavoro (full detail shape).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/quotes/{id}/line-items/batch:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Quote id (UUID).
        schema: { type: string }
    post:
      tags: [Quotes]
      summary: Mutate quote line items in a batch
      description: |
        Adds, updates, removes, and reorders the quote's line items in a
        single atomic diff, then returns the full quote with recomputed
        totals and the updated `line_items` array. The top-level quote
        PATCH is metadata-only; all line-item mutation flows through this
        endpoint. Identical wire shape to the worksheet batch endpoint.

        **Operations** (all arrays optional; empty body is a no-op):
        `add`, `update` (full replace by id), `remove` (by id),
        `reorder`. An id may appear in at most one of
        `remove`/`update`/`reorder`. As with worksheets, `update` is a
        remove+add internally so an updated line receives a NEW id;
        `tax_rate` is resolved server-side from `vat_code`.

        Quotes are estimates — there is no invoice-freeze rule and
        article references on lines do not move inventory stock (they
        are validated for ownership only).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LineItemsBatchRequest' }
            example:
              add:
                - description: Estimated labour
                  quantity: 2
                  unit_type: pz
                  unit_price: 35
                  unit_discount_rate: 0
                  vat_code: '22'
      responses:
        '200':
          description: Batch applied. Returns the updated quote (with `line_items`).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Invoices ──────────────
  /v1/quotes/{id}/transitions:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Quote id (UUID).
        schema: { type: string }
    post:
      tags: [Quotes]
      summary: Apply a status transition
      description: |
        Drives the quote lifecycle: `waiting` → `accepted` /
        `rejected`. Any other `action` value returns 422.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteTransitionRequest' }
            example:
              action: accepted
      responses:
        '200':
          description: Transition applied. Returns the post-transition entity.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/receipts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Receipts]
      summary: List receipts (sticky date range, ?q= customer filter, unpaginated)
      responses:
        '200': { description: Receipts + date_filter. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Receipts]
      summary: Create a receipt from a worksheet
      description: Body {worksheet_id, payment_method}. 409 when the worksheet already has a receipt.
      responses:
        '201': { description: Receipt (line items included). }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/receipts/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Receipts]
      summary: Retrieve a receipt (line items included)
      responses:
        '200': { description: Receipt. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Receipts]
      summary: Update a draft receipt (title, comments, email, payment_method, sezionale_id)
      responses:
        '200': { description: Updated receipt. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Receipts]
      summary: Delete a receipt (issued ones must be voided from the web first)
      responses:
        '200': { description: The removed receipt. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/receipts/{id}/issue:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Receipts]
      summary: Issue (paper numbering; 409 with electronic receipts enabled)
      responses:
        '200': { description: Issued receipt. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/receipts/{id}/payments:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Receipts]
      summary: List a receipt's payments
      responses:
        '200': { description: Payments, oldest first (unpaginated). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Receipts]
      summary: Register a payment (PaymentCreateRequest; method falls back to the receipt's)
      responses:
        '201': { description: Payment. }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/receipts/{id}/payments/{paymentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: paymentID
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Receipts]
      summary: Remove a payment from a receipt
      responses:
        '200': { description: The removed payment. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Expenses (spese) + Suppliers (fornitori) ──────────────
  # Spese: list (?q supplier substring, ?status to_pay|paid|expired,
  # sticky date range, cursor pagination), status-counts, get (line
  # items + installments), totals-first create {supplier_id, totals,
  # header fields}, patch (header fields + totals; line items preserved),
  # transitions {action: paid|to_pay}, delete. Suppliers: standard
  # list (?q) / get / create {name…} / patch / delete.
  /v1/report/{widget}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: widget
        in: path
        required: true
        description: |
          Widget key. One route per data widget so clients fetch widgets
          independently (progressive loading) and new widgets are purely
          additive. Current keys: `kpis`, `sales-chart`,
          `expenses-chart`, `quote-conversion`, `worksheet-status`,
          `outstanding-worksheets`, `upcoming-appointments`,
          `top-customers`, `top-services`, `worksheet-margin-chart`,
          `worksheet-margins`. Unknown keys → 404.
        schema: { type: string }
    get:
      tags: [Reports]
      summary: One data widget of the Situazione report
      description: |
        Returns the data behind one widget of the mobile Situazione
        screen, wrapped in a `report_widget` envelope that echoes the
        applied period. Backed by the same report services as the web
        report page; requires the `reports.read` permission (403
        otherwise).

        The period uses the same sticky per-user date filter as the list
        endpoints and the web report page (view key `report`): no date
        params restores the user's saved selection (default: last 12
        months); an explicit `preset`/`from`/`to` is persisted and
        reused. `preset: all` spans from the company's creation to today.

        Chart widgets bucket by **day** for periods up to 31 days and by
        **month** otherwise — `x_axis` entries are always `YYYY-MM-DD`,
        so clients re-bucket freely (the mobile app aggregates daily
        axes into weeks).
      parameters:
        - name: preset
          in: query
          required: false
          description: |
            Canonical period selection: `rel30` / `rel90` / `rel365`
            (rolling, resolved server-side — no `from`/`to` needed),
            `year` / `Q1`..`Q4` / `month` / `custom` (absolute — both
            `from` and `to` required), or `all`.
          schema: { type: string }
        - name: from
          in: query
          required: false
          description: 'Period start (default: 12 months ago).'
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: 'Period end, inclusive (default: today).'
          schema: { type: string, format: date }
        - name: limit
          in: query
          required: false
          description: |
            List widgets only — rows returned. Default 5, max 50 (the
            repository cap; raise both to go beyond).
          schema: { type: integer, minimum: 1, maximum: 50 }
        - name: metric
          in: query
          required: false
          description: '`worksheet-margins` only — ranking metric.'
          schema: { type: string, enum: [eur, pct] }
        - name: direction
          in: query
          required: false
          description: '`worksheet-margins` only.'
          schema: { type: string, enum: [best, worst] }
      responses:
        '200':
          description: Widget data.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportWidgetEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Manufacturers ──────────────
  /v1/sezionali:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Invoices]
      summary: List invoice sezionali
      description: |
        The company's invoice numbering registers, behind the fattura
        edit form's Sezionale select. Unpaginated — the set is a
        handful of rows. `has_more` is always `false`.
      responses:
        '200':
          description: Sezionali.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Sezionale' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Settings]
      summary: Create a sezionale
      operationId: sezionaleCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/sezionali/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Settings]
      summary: Delete sezionale
      operationId: sezionaleDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Settings]
      summary: Update sezionale
      operationId: sezionalePatch
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/suppliers:
    parameters: [{ $ref: '#/components/parameters/APIVersion' }]
    get:
      tags: [Suppliers]
      summary: List suppliers (?q= name substring, cursor pagination)
      responses:
        '200': { description: Suppliers. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Suppliers]
      summary: Create a supplier
      responses:
        '201': { description: Supplier. }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/suppliers/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Suppliers]
      summary: Retrieve a supplier
      responses:
        '200': { description: Supplier. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Suppliers]
      summary: Update a supplier
      responses:
        '200': { description: Updated supplier. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Suppliers]
      summary: Delete a supplier
      responses:
        '200': { description: The removed supplier. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Credit Notes ──────────────
  /v1/team:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Settings]
      summary: List teams
      operationId: teamList
      description: |
        (read-only; invites and permissions stay web-side).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/team/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Settings]
      summary: Retrieve a team
      operationId: teamGet
      description: |
        (read-only): identity, stato, ruolo and the functional-area permissions.
        Deactivated members are included so their card still opens.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/tyresets:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [TyreSets]
      summary: List tyre sets
      operationId: tyreSetList
      description: |
        ?status, ?customer_id, cursor pagination).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [TyreSets]
      summary: Create a tyre set
      operationId: tyreSetCreate
      description: |
        customer's vehicle (status starts deposited, web parity).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/tyresets/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [TyreSets]
      summary: Replace tyre status counts
      operationId: tyreSetStatusCounts
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/tyresets/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [TyreSets]
      summary: Delete tyre set
      operationId: tyreSetDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    get:
      tags: [TyreSets]
      summary: Retrieve a tyre set
      operationId: tyreSetGet
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [TyreSets]
      summary: Update tyre set
      operationId: tyreSetPatch
      description: |
        the descriptive fields (status moves via transitions).
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/tyresets/{id}/transitions:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    post:
      tags: [TyreSets]
      summary: Change the status of tyre set
      operationId: tyreSetTransition
      description: |
        {action: deposited|mounted|disposed|collected}.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vat-rates:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Articles]
      summary: List company VAT rates
      description: |
        The VAT rates enabled for the authenticated user's company, in
        the company's configured order — the first row is the default
        for new line items. Codes that are disabled but still referenced
        by existing documents are accepted on write but not offered
        here.

        Intentionally unpaginated: the set is a handful of codes.
        `has_more` is always `false`.
      responses:
        '200':
          description: Company-enabled VAT rates.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/VatRate' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Settings]
      summary: Replace vat rate
      operationId: vatRateSet
      description: |
        set with the given ordered codes.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vat-rates/catalog:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Settings]
      summary: Vat rate catalog
      operationId: vatRateCatalog
      description: |
        global catalog with the company's enabled marks.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicle-field-settings:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Settings]
      summary: Retrieve a vehicle field settings
      operationId: vehicleFieldSettingsGet
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Settings]
      summary: Replace vehicle field settings
      operationId: vehicleFieldSettingsPut
      description: |
        replaces visibility toggles and custom field definitions.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicle-sales:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Dealership]
      summary: List vehicle sales
      operationId: vehicleSaleList
      description: |
        q       substring match on customer / manufacturer / model / plate
        status  "draft" | "signed" | "delivered" (Bozza / Contratto firmato /
        Consegnata) — omitted returns every status ("Tutte")
        from, to, preset   sticky "vehicle_sales" date range (default last 12 months)
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicle-sales/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Dealership]
      summary: Vehicle sale status counts
      operationId: vehicleSaleStatusCounts
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicle-sales/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Dealership]
      summary: Retrieve a vehicle sale
      operationId: vehicleSaleGet
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicle-stocks:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Dealership]
      summary: List vehicle stocks
      operationId: vehicleStockList
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Vehicles]
      summary: List vehicles
      description: |
        Returns vehicles in the authenticated user's company, newest
        first. Search by name with `?q=`; filter to a single customer
        with `?customer_id=`; paginate with cursor-based `?limit=` +
        `?starting_after=`.

        The cursor is opaque — clients MUST NOT parse it. Pass back
        the value verbatim. Today it encodes an offset; tomorrow it
        may encode a `(created_at, id)` tuple without a client-visible
        change.

        `has_more` is `true` when more results exist beyond this page.
        Drive the "load more" UI off this flag, not off `data.length`
        — the last page may still contain a full `limit` worth of
        results.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on vehicle model / license.
          schema: { type: string }
        - name: customer_id
          in: query
          required: false
          description: |
            Restrict the result set to vehicles owned by a single
            customer. Useful for the "customer detail → vehicles"
            mobile screen.
          schema: { type: string }
        - name: sort
          in: query
          required: false
          description: |
            Sort key (default `date` = creation date). Named keys are
            mapped server-side to whitelisted columns.
          schema:
            type: string
            enum: [date, customer, vehicle, license]
        - name: direction
          in: query
          required: false
          description: Sort direction (default `desc`).
          schema: { type: string, enum: [asc, desc] }
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 20, max 100. Out-of-range values clamp
            silently to the defaults — never error.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: |
            Opaque cursor from a prior response's next-page link. Pass
            verbatim. Omit on the first page.
          schema: { type: string }
      responses:
        '200':
          description: Page of vehicles.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Vehicle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Vehicles]
      summary: Create a vehicle
      description: |
        Creates a new vehicle in the authenticated user's company.
        `company_id` is always derived from the bearer token and
        `created_by` from the authenticated user — neither can be
        set from the body.

        Required: `model`, `manufacturer_id`.

        Rental fields (`is_rental`, `rental_*`) are not accepted on
        create; rental setup is a separate flow.

        Returns the fully materialized vehicle (including the joined
        `manufacturer_name` and `customer_name`).

        Foreign-key references in the body (`customer_id`, etc.) must
        resolve to resources owned by the authenticated user's company.
        Unreachable refs return 422 `parameter_invalid` pointing at the
        offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VehicleCreateRequest' }
            example:
              manufacturer_id: man_1234
              model: Panda
              license: AB123CD
              customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd
              km: 84210.5
      responses:
        '201':
          description: Vehicle created.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
    get:
      tags: [Vehicles]
      summary: Retrieve a vehicle
      description: |
        Returns a single vehicle by id, scoped to the authenticated
        user's company. Unknown ids, ids from another company, and
        soft-deleted rows all return 404 — never 403, never 200 with
        deleted_at populated.
      responses:
        '200':
          description: Vehicle found.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Vehicles]
      summary: Update a vehicle
      description: |
        Partial update following the design philosophy:

        - **Omitted JSON keys** leave the corresponding entity fields
          untouched.
        - **Explicit JSON `null`** clears the field (SQL NULL).
        - **Concrete values** overwrite the entity field.

        `model` and `manufacturer_id` are NOT NULL in the schema;
        sending either as `null` is a 422 `parameter_invalid` error.

        `custom_fields` is patched as a whole-object replacement
        (omit = unchanged, null = clear, object = overwrite). Per-key
        merging is intentionally not supported.

        Rental fields are read-only here — use the rental setup flow
        to change them.

        Returns the fully materialized post-update vehicle.

        Foreign-key references in the body (`customer_id`, etc.) must
        resolve to resources owned by the authenticated user's company.
        Unreachable refs return 422 `parameter_invalid` pointing at the
        offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VehiclePatchRequest' }
            example:
              license: EF456GH
              km: 90100
              customer_id: null
      responses:
        '200':
          description: Vehicle updated. Returns the full post-update entity.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Vehicles]
      summary: Soft-delete a vehicle
      description: |
        Marks the vehicle as deleted by stamping `deleted_at` with the
        server clock. The row stays in the database; subsequent
        GET/PATCH/DELETE all 404.

        Returns the deleted resource (200) rather than 204 No Content
        so the response shape is consistent across the API and the
        mobile UI can render an undo banner with the timestamp.
      responses:
        '200':
          description: Vehicle soft-deleted. `deleted_at` is populated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/checkins:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
    get:
      tags: [Vehicles]
      summary: Check-in (Accettazioni) history for a vehicle
      description: |
        Every check-in linked to the vehicle, newest first, without
        line items. Unpaginated (`has_more` always false).
      responses:
        '200':
          description: The vehicle's check-ins.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Checkin' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/documents:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
    post:
      tags: [Vehicles]
      summary: Upload a document to a vehicle
      description: |
        `multipart/form-data` — mirrors the customer document upload:
        part `file` (required, max 25MB) and field `description`
        (display label; the app requires it, the server falls back to
        the file name — web form parity). Storage layout matches the
        web upload, so documents from either surface are identical.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                description: { type: string }
      responses:
        '201':
          description: The stored document.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VehicleDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/documents/{documentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Vehicles]
      summary: Delete vehicle document
      operationId: vehicleDocumentDelete
      description: |
        VehicleDocumentDelete handles
        DELETE /v1/vehicles/{id}/documents/{documentID}.
      responses:
        '200':
          description: success (the removed document)
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VehicleDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/documents/{documentID}/download:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Document id (UUID).
        schema: { type: string }
    get:
      tags: [Vehicles]
      summary: Download a vehicle document
      description: |
        Streams the stored bytes with the document's Content-Type and
        an attachment Content-Disposition. The target of every
        vehicle_document `url`.
      responses:
        '200':
          description: The file bytes.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/quotes:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
    get:
      tags: [Vehicles]
      summary: Quote history for a vehicle
      description: |
        Every quote linked to the vehicle, newest first, without line
        items. Unpaginated (`has_more` always false).
      responses:
        '200':
          description: The vehicle's quotes.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Quote' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/vehicles/{id}/worksheets:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Vehicle id (UUID).
        schema: { type: string }
    get:
      tags: [Vehicles]
      summary: Worksheet history for a vehicle
      description: |
        Every worksheet linked to the vehicle, newest first, without
        line items. Unpaginated (`has_more` always false) — web
        vehicle-page parity.
      responses:
        '200':
          description: The vehicle's worksheets.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Worksheet' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/work-categories:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Worksheets]
      summary: List work categories (Tipo intervento)
      description: |
        The company's work-category catalog behind the worksheet and
        quote forms. Intentionally unpaginated: the set is small and
        company-curated. `has_more` is always `false`.
      responses:
        '200':
          description: Work categories.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WorkCategory' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Settings]
      summary: Create a work category
      operationId: workCategoryCreate
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/work-categories/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Settings]
      summary: Delete work category
      operationId: workCategoryDelete
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Settings]
      summary: Update work category
      operationId: workCategoryPatch
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Worksheets]
      summary: List worksheets
      description: |
        Returns worksheets in the authenticated user's company, newest
        first. Filter by status with `?status=`, by customer with
        `?customer_id=`, search customer names with `?q=`, and bound by
        `created_at` with `?from=` / `?to=` / `?preset=`. Paginate with
        cursor-based `?limit=` + `?starting_after=`.

        The date filter is sticky per user: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months.
        The applied range is echoed in `date_filter`. Pass `?preset=all`
        to list across all time.

        Line items are **not** included in the list shape — fetch the
        detail endpoint when full line-item data is needed.

        The cursor is opaque — clients MUST NOT parse it. Pass back
        the value verbatim.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match on the customer's full name.
          schema: { type: string }
        - name: status
          in: query
          required: false
          description: |
            Filter to a single lifecycle state. Sending an unrecognized
            value returns 422 instead of silently coercing.
          schema:
            type: string
            enum: [draft, waiting, started, completed, delivered, archived]
        - name: customer_id
          in: query
          required: false
          description: Restrict results to a single customer's worksheets.
          schema: { type: string }
        - name: sort
          in: query
          required: false
          description: |
            Sort key (default `date` = creation date). Named keys are
            mapped server-side to whitelisted columns.
          schema:
            type: string
            enum: [date, number, customer, vehicle, total, outstanding]
        - name: direction
          in: query
          required: false
          description: Sort direction (default `desc`).
          schema: { type: string, enum: [asc, desc] }
        - name: from
          in: query
          required: false
          description: Inclusive lower bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          description: Inclusive upper bound on `created_at` (YYYY-MM-DD).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
        - name: limit
          in: query
          required: false
          description: Page size. Default 20, max 100.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          required: false
          description: Opaque cursor from a prior response. Omit on the first page.
          schema: { type: string }
      responses:
        '200':
          description: Page of worksheets (without line items).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    required: [date_filter]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Worksheet' }
                      date_filter: { $ref: '#/components/schemas/DateFilter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Worksheets]
      summary: Create a worksheet
      description: |
        Creates a new worksheet, owned by the authenticated user's
        company. Server-controlled fields (`id`, `company_id`,
        `created_by`, `seq_number`, `status`, all `*_at` timestamps,
        totals) are filled in automatically. Status always starts
        as `draft`.

        `customer_id` is required. Line items are not accepted at
        creation time — a dedicated sub-resource will add/remove
        them in a later pass.

        Foreign-key references in the body (`customer_id`, `vehicle_id`,
        `work_category_id`, `assigned_to`) must resolve to resources
        owned by the authenticated user's company. Unreachable refs
        return 422 `parameter_invalid` pointing at the offending field.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetCreateRequest' }
            example:
              customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd
              vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55
              title: Tagliando 60.000 km
              km: 61240
      responses:
        '201':
          description: Worksheet created. Returns the fully-materialized resource.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/status-counts:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Worksheets]
      summary: Count worksheets per lifecycle state
      description: |
        Returns how many worksheets sit in each status, for the tab
        counters above the mobile list. Accepts the same filters as
        `GET /v1/worksheets` minus `status` and pagination (`q`,
        `customer_id`, `from`/`to`/`preset` with the same sticky
        date-filter semantics), so the numbers always match what the
        list shows. The applied range is echoed in `date_filter`.
      parameters:
        - name: q
          in: query
          required: false
          description: Substring match against customer name.
          schema: { type: string }
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/DatePreset'
      responses:
        '200':
          description: Counts per status.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorksheetStatusCounts' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Worksheet id (UUID).
        schema: { type: string }
    get:
      tags: [Worksheets]
      summary: Retrieve a worksheet
      description: |
        Returns a single worksheet (with embedded line items) by id,
        scoped to the authenticated user's company. Unknown ids, ids
        from another company, and soft-deleted rows all return 404 —
        never 403.
      responses:
        '200':
          description: Worksheet found. Includes `line_items`.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Worksheets]
      summary: Update a worksheet
      description: |
        Partial update following the standard three-state PATCH
        semantics (omitted = unchanged, `null` = clear, value = set).

        Only top-level editable fields are exposed here. Use
        `POST /v1/worksheets/{id}/transitions` to change `status`.
        Line-item mutation is out of scope for v1 — a dedicated
        sub-resource will land in a later pass.

        Fields intentionally NOT patchable: `id`, `company_id`,
        `created_by`, `seq_number`, `status`, `customer_id`,
        `vehicle_id`, all totals, all `*_at` timestamps,
        `line_items`, `documents`.

        Foreign-key references in the body (`work_category_id`,
        `assigned_to`) must resolve to resources owned by the
        authenticated user's company. Unreachable refs return 422
        `parameter_invalid` pointing at the offending field.

        **Frozen worksheets** — when the worksheet's linked invoice
        has been issued, the worksheet is frozen: its totals are
        now part of the invoice's accounting record and must not
        desync. PATCH returns 409 `conflict`. Cancel the invoice
        with a credit note before editing.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetPatchRequest' }
            example:
              title: Tagliando 60.000 km — completato
              km: 61310
              internal_comments: null
      responses:
        '200':
          description: Worksheet updated. Returns the full post-update entity.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Worksheets]
      summary: Soft-delete a worksheet
      description: |
        Marks the worksheet as deleted by stamping `deleted_at` with
        the server clock. The row stays in the database; subsequent
        GET/PATCH/DELETE all 404.

        Returns the deleted resource (200) rather than 204 No Content
        for shape consistency with the rest of the API.

        **Frozen worksheets** — refuses to soft-delete when the
        worksheet's linked invoice has been issued (would orphan
        the invoice's basis). Returns 409 `conflict`. Cancel the
        invoice with a credit note first.
      responses:
        '200':
          description: Worksheet soft-deleted. `deleted_at` is populated.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/checkins:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Worksheet id (UUID).
        schema: { type: string }
    get:
      tags: [Worksheets]
      summary: Accettazioni linked to a scheda lavoro
      description: |
        Every check-in whose `worksheet_id` points at this scheda (set by
        `POST /v1/checkins/{id}/convert` and the web conversion). Newest
        first, without line items. Unpaginated (`has_more` always false).
      responses:
        '200':
          description: Linked accettazioni.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Checkin' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/documents:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    post:
      tags: [Worksheets]
      summary: Create a worksheet document
      operationId: worksheetDocumentCreate
      description: |
        multipart/form-data:

        file         required — the document bytes (max 25MB)
        description  optional — display label; defaults to the file name
      responses:
        '201':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorksheetDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/documents/{documentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    delete:
      tags: [Worksheets]
      summary: Delete worksheet document
      operationId: worksheetDocumentDelete
      description: |
        WorksheetDocumentDelete handles
        DELETE /v1/worksheets/{id}/documents/{documentID}.
      responses:
        '200':
          description: success (the removed document)
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorksheetDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/documents/{documentID}/download:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
      - name: documentID
        in: path
        required: true
        description: Identifier of the target resource.
        schema: { type: string }
    get:
      tags: [Worksheets]
      summary: Download worksheet document
      operationId: worksheetDocumentDownload
      description: |
        WorksheetDocumentDownload handles
        GET /v1/worksheets/{id}/documents/{documentID}/download.
      responses:
        '200':
          description: success
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/line-items/batch:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Worksheet id (UUID).
        schema: { type: string }
    post:
      tags: [Worksheets]
      summary: Mutate worksheet line items in a batch
      description: |
        Adds, updates, removes, and reorders the worksheet's line items
        in a single atomic diff, then returns the full worksheet with
        recomputed totals and the updated `line_items` array. The
        top-level worksheet PATCH is metadata-only; all line-item
        mutation flows through this endpoint.

        **Operations** (all arrays optional; empty body is a no-op):
        `add` creates new lines, `update` replaces an existing line's
        fields by id, `remove` deletes lines by id, `reorder` sets the
        position of existing untouched lines. An id may appear in at
        most one of `remove`/`update`/`reorder`.

        **Update is remove+add internally** so article inventory stock
        reconciles through the normal path; an updated line therefore
        receives a NEW id in the response — re-read ids from the
        returned `line_items` rather than assuming stability.

        **`tax_rate` is server-resolved** from each line's `vat_code`;
        clients never send rates or totals.

        **Frozen worksheets** — blocked when the worksheet's linked
        invoice has been issued (mutating lines would desync invoice
        totals). Returns 409 `conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LineItemsBatchRequest' }
            example:
              add:
                - description: Brake pads (front)
                  quantity: 1
                  unit_type: kit
                  unit_price: 89.9
                  unit_discount_rate: 0
                  vat_code: '22'
                  article_id: null
              update:
                - id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10
                  description: Labour — brake service
                  quantity: 1.5
                  unit_type: pz
                  unit_price: 40
                  unit_discount_rate: 10
                  vat_code: '22'
              remove:
                - 0b4a2d6f-1c3e-4f5a-8b9c-7d6e5f4a3b2c
              reorder:
                - id: 9d8c7b6a-5f4e-3d2c-1b0a-9e8d7c6b5a40
                  position: 1
      responses:
        '200':
          description: Batch applied. Returns the updated worksheet (with `line_items`).
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ────────────── Quotes ──────────────
  /v1/worksheets/{id}/payments:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Worksheets]
      summary: List a worksheet's payments
      description: |
        Payments registered against the worksheet (acconti/saldi).
        They physically hang off the worksheet's invoice, but the mobile
        API exposes them worksheet-scoped so clients never deal with the
        invoice indirection. Unpaginated.
      responses:
        '200':
          description: Payments, oldest first.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, url]
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Payment' }
                  has_more: { type: boolean, const: false }
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Worksheets]
      summary: Register a payment on a worksheet
      description: |
        Mirrors the web worksheet flow: the payment attaches to the
        worksheet's invoice — a draft is created from the worksheet when
        none exists yet — and the invoice paid-state is refreshed.
        `payment_method` falls back to the customer's preferred method,
        then the company default, when missing or invalid.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentCreateRequest' }
            example:
              amount: 150.0
              payment_method: MP01
              payment_date: '2026-07-16'
      responses:
        '201':
          description: Payment registered.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/payments/{paymentID}:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: paymentID
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Worksheets]
      summary: Remove a payment from a worksheet
      description: |
        Hard-deletes the payment (payments have no soft-delete) and
        refreshes the invoice paid-state. The payment must belong to
        the worksheet — otherwise 404.
      responses:
        '200':
          description: The removed payment.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/worksheets/{id}/transitions:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
      - name: id
        in: path
        required: true
        description: Worksheet id (UUID).
        schema: { type: string }
    post:
      tags: [Worksheets]
      summary: Apply a status transition
      description: |
        Moves the worksheet between lifecycle states. Modeled as an
        RPC rather than a PATCH on `status` so the lifecycle is
        visible in the URL surface and the server retains exclusive
        control over which `*_at` columns get stamped.

        Supported `action` values mirror the entity status constants:
        `draft`, `waiting`, `started`, `completed`, `delivered`,
        `archived`. Any other value is rejected with 422.

        **Frozen worksheets** — transitions are blocked when the
        worksheet's linked invoice has been issued. A status flip
        on a frozen worksheet could trigger downstream side effects
        (e.g. moving back to draft would unwind the basis the
        invoice was minted from). Returns 409 `conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetTransitionRequest' }
            example:
              action: started
      responses:
        '200':
          description: Transition applied. Returns the post-transition entity.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Worksheet' }
        '400': { $ref: '#/components/responses/MalformedRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/whoami:
    parameters:
      - $ref: '#/components/parameters/APIVersion'
    get:
      tags: [Authentication]
      summary: Describe the calling API key
      operationId: whoAmI
      description: |
        Returns the key making the request: its name, environment and the
        scopes it was granted, plus the company it authorises.

        Deliberately unscoped — every integrator's first call, and the
        cheapest answer to "is my key working". It reveals nothing the
        caller does not already hold.

        A 200 means the key is valid AND the company's plan includes API
        access. 401 means the key is unknown, revoked or expired; 402
        means the plan does not include API access.
      responses:
        '200':
          description: The calling key.
          headers:
            X-API-Version: { $ref: '#/components/headers/APIVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WhoAmI' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '500': { $ref: '#/components/responses/InternalError' }

components:

  # ───────────────────────────────────────────────────────────────
  # Security
  # ───────────────────────────────────────────────────────────────
  #
  # When copying this spec for the public API tier, the entire
  # `securitySchemes` block becomes:
  #
  #   PublicAPIKey:
  #     type: http
  #     scheme: bearer
  #     bearerFormat: APIKey
  #     description: |
  #       Long-lived API key issued from the dashboard. Format:
  #       `sk_live_<32 bytes base62>` (or `sk_test_…` for sandbox).
  #
  # …and the top-level `security:` becomes `- PublicAPIKey: []`.
  # No other change.

  securitySchemes:
    PublicAPIKey:
      type: http
      scheme: bearer
      bearerFormat: APIKey
      description: |
        Long-lived API key minted from Impostazioni → Accesso API.
        Format `ofk_live_<43 chars>` (`ofk_test_` outside production).
        Send it as `Authorization: Bearer ofk_live_…`.

        A key authorises a COMPANY and carries per-resource scopes;
        a call outside its scopes answers 403 `insufficient_scope`.
        Keys are shown once at creation and stored only as a digest.

  # ───────────────────────────────────────────────────────────────
  # Shared parameters & headers
  # ───────────────────────────────────────────────────────────────

  parameters:
    APIVersion:
      name: X-API-Version
      in: header
      required: true
      description: |
        The client's declared revision: the dated `/v1` contract this
        client build was developed against. Floor-checked, not an
        allowlist — must be a `YYYY-MM-DD` date and at least the minimum
        supported revision (`api_version_retired` below it, meaning the
        client build is too old); dates newer than the deployed server
        are accepted and served with current behaviour. Clients should
        also self-identify via the optional headers `X-Client`
        (`officina-mobile/1.0.0 (ios)`), `X-App-Version` (`1.0.0`),
        `X-Platform` (`ios`) and `X-OS-Version` (`18.5`) — all stamped
        on the request logs (`client`, `app_version`, `platform`,
        `os_version`) so revision adoption and platform-specific issues
        are measurable before the floor is raised.
        Current: `2026-07-15`. Minimum: `2026-07-15` (single pre-launch
        revision). See the README registry and
        `apihttp.CurrentAPIVersion` / `MinSupportedAPIVersion` for the
        source of truth.
      schema:
        type: string
        pattern: '^\d{4}-\d{2}-\d{2}$'
        example: '2026-07-15'

    DatePreset:
      name: preset
      in: query
      required: false
      description: |
        Date filter preset. Relative presets (`rel30`/`rel90`/`rel365`)
        are resolved server-side from "now" and ignore `from`/`to`.
        Absolute presets (`year`, `Q1`-`Q4`, `month`, `custom`) require
        both `from` and `to`. `all` returns everything (no time bound).
        Unknown values are rejected with 422.

        The selection is sticky per user/per view: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; the effective range is echoed back in `date_filter`.
      schema:
        type: string
        enum: [rel30, rel90, rel365, year, Q1, Q2, Q3, Q4, month, custom, all]

  headers:
    APIVersion:
      description: Echoes the `X-API-Version` value the server processed.
      schema:
        type: string

  # ───────────────────────────────────────────────────────────────
  # Schemas — request and response DTOs
  # ───────────────────────────────────────────────────────────────

  schemas:
    WhoAmI:
      type: object
      description: |
        The API key making the request, as returned by GET /v1/whoami.
      required: [object, id, name, environment, scopes, company]
      properties:
        object:
          type: string
          description: always "api_key"
        id:
          type: string
        name:
          type: string
          description: The label the workshop gave the key.
        environment:
          type: string
          enum: [live, test]
          description: Matches the token prefix (ofk_live_ / ofk_test_).
        scopes:
          type: array
          description: Granted scopes, e.g. "customers.read".
          items:
            type: string
        company:
          type: object
          required: [object, id, name]
          properties:
            object:
              type: string
              description: always "company"
            id:
              type: string
            name:
              type: string


    # ─── Auth requests ───

    LoginMethodRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          description: Email to resolve the credential step for.

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
          minLength: 1

    SignOnTokenRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email

    LoginWithTokenRequest:
      type: object
      required: [email, sign_on_token]
      properties:
        email:
          type: string
          format: email
        sign_on_token:
          type: string
          description: One-time code received via email.
          minLength: 1

    Profile:
      type: object
      required: [object, id, first_name, last_name, email, phone, phone_national, role, permissions, all_permissions, modules]
      description: |
        The authenticated user's own account (Profilo personale).
        Email is read-only — the login email cannot be changed from
        this surface, matching the web.

        `permissions` + `modules` are the app's gating surface: the
        user's granted permission keys and the company's feature
        flags, resolved server-side with the same semantics the web
        sidebar (dashboard-sidebar.html) reads them. The client hides
        navigation/actions from them; the API remains the enforcement
        layer.
      properties:
        object:         { type: string, const: profile }
        id:             { type: string }
        first_name:     { type: string }
        last_name:      { type: string }
        email:          { type: string, format: email }
        phone:          { type: [string, 'null'], description: 'Stored E.164 value.' }
        phone_national: { type: [string, 'null'], description: 'phone minus the +39 dial code, for the fixed-prefix input.' }
        role:           { type: [string, 'null'] }
        permissions:
          type: array
          items: { type: string }
          description: Sorted permission keys, e.g. "invoices.read".
        all_permissions:
          type: boolean
          description: |
            True when the user holds every permission in the system
            (owner-equivalent). Gates owner-only surfaces like the AI
            assistant — exposed because the client cannot know the
            full permission catalog.
        modules:
          type: object
          required: [checkins, electronic_invoices, expenses, dealership, vehicle_rental, tyre_storage, service_catalog, inventory, technical_data, license_plate_search, public_profile, ai_assistant, has_worksheets]
          description: |
            Company feature flags. `technical_data` already folds in
            the subscription check (Company.CanUseTechnicalData);
            `has_worksheets` backs the web's "Situazione appears
            after the first worksheet" rule.
          properties:
            checkins:            { type: boolean }
            electronic_invoices: { type: boolean }
            expenses:            { type: boolean }
            dealership:          { type: boolean }
            vehicle_rental:      { type: boolean }
            tyre_storage:        { type: boolean }
            service_catalog:     { type: boolean }
            inventory:           { type: boolean }
            technical_data:      { type: boolean }
            license_plate_search: { type: boolean }
            public_profile:      { type: boolean }
            ai_assistant:        { type: boolean }
            has_worksheets:      { type: boolean }

    # ─── Customer requests ───

    CustomerCreateRequest:
      type: object
      required: [customer_type]
      description: |
        Body of `POST /v1/customers`. `company_id` and `created_by`
        are always derived from the authenticated bearer token and
        MUST NOT appear in the body.

        Required: `customer_type`. Everything else is optional;
        omitted keys map to NULL on the entity. There is no
        three-state semantic on create — there's nothing to "leave
        untouched".

        Fields not listed here are intentionally NOT settable on
        create: `id`, `company_id`, `created_by`, system timestamps,
        and all computed display fields.
      properties:
        customer_type:    { type: string }
        full_name:        { type: [string, 'null'] }
        email:            { type: [string, 'null'], format: email }
        phone:
          type: [string, 'null']
          description: |
            National number as typed (e.g. "333 1234567"). Combined
            with `phone_dial_code` and validated server-side via the
            web customer-form rules ("+39" allows Italian mobiles and
            landlines; other known dial codes get a basic E.164 check);
            stored and returned as one E.164 string. Invalid pairs are
            rejected with 422 `parameter_invalid`.
        phone_dial_code:
          type: [string, 'null']
          description: E.164 dial code (default "+39"). Only read alongside `phone`.
        address:          { type: [string, 'null'] }
        city:             { type: [string, 'null'] }
        zip_code:         { type: [string, 'null'] }
        province:         { type: [string, 'null'] }
        tax_id:           { type: [string, 'null'] }
        fiscal_code:      { type: [string, 'null'] }
        pec:              { type: [string, 'null'] }
        destination_code: { type: [string, 'null'] }
        payment_method:   { type: [string, 'null'] }
        comments:         { type: [string, 'null'] }
        birthday:         { type: [string, 'null'], format: date-time }
        price_list_id:    { type: [string, 'null'] }
      additionalProperties: false

    CustomerPatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/customers/{id}`. Every field is optional on
        the wire and carries three-state semantics:

        - Field omitted → leave the entity field untouched.
        - Field present with value `null` → clear the entity field.
        - Field present with a value → set the entity field.

        Fields not listed here are intentionally NOT patchable through
        the API: `id`, `company_id`, `created_by`, system timestamps
        (`created_at`, `updated_at`, `deleted_at`), and computed/derived
        fields (`total_spent`, `next_appointment`, `last_vehicle_*`).
        Documents are managed via the documents sub-resource.

        `customer_type` is NOT NULL at the entity layer; sending it as
        explicit `null` is rejected with 422 `parameter_invalid`.
      properties:
        customer_type:    { type: string }
        full_name:        { type: [string, 'null'] }
        email:            { type: [string, 'null'], format: email }
        phone:
          type: [string, 'null']
          description: |
            National number as typed (e.g. "333 1234567"). Combined
            with `phone_dial_code` and validated server-side via the
            web customer-form rules ("+39" allows Italian mobiles and
            landlines; other known dial codes get a basic E.164 check);
            stored and returned as one E.164 string. Invalid pairs are
            rejected with 422 `parameter_invalid`.
        phone_dial_code:
          type: [string, 'null']
          description: E.164 dial code (default "+39"). Only read alongside `phone`.
        address:          { type: [string, 'null'] }
        city:             { type: [string, 'null'] }
        zip_code:         { type: [string, 'null'] }
        province:         { type: [string, 'null'] }
        tax_id:           { type: [string, 'null'] }
        fiscal_code:      { type: [string, 'null'] }
        pec:              { type: [string, 'null'] }
        destination_code: { type: [string, 'null'] }
        payment_method:   { type: [string, 'null'] }
        comments:         { type: [string, 'null'] }
        birthday:         { type: [string, 'null'], format: date-time }
        price_list_id:    { type: [string, 'null'] }
      additionalProperties: false

    # ─── Vehicle requests ───

    VehicleDocument:
      type: object
      required: [object, id, name, content_type, description, url, file_size_bytes, created_at]
      description: |
        One attachment on a vehicle (Documenti Allegati). `url` is the
        authenticated download route — fetch it with the bearer and
        hand the bytes to the OS viewer/share sheet.
      properties:
        object:          { type: string, const: vehicle_document }
        id:              { type: string }
        name:            { type: string, description: 'Original file name.' }
        content_type:    { type: string }
        description:     { type: [string, 'null'] }
        url:             { type: string, example: '/v1/vehicles/8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55/documents/3b91ee0c-19b4-4f0a-9c2e-7d51a0c88e14/download' }
        file_size_bytes: { type: integer }
        created_at:      { type: string, format: date-time }

    VehicleCreateRequest:
      type: object
      required: [manufacturer_id, model]
      description: |
        Body of `POST /v1/vehicles`. `company_id` and `created_by`
        are always derived from the authenticated bearer token and
        MUST NOT appear in the body. Rental fields (`is_rental`,
        `rental_*`) are also not accepted — rental setup is a
        separate flow.
      properties:
        manufacturer_id:        { type: string }
        model:                  { type: string }
        license:                { type: [string, 'null'] }
        variant:                { type: [string, 'null'] }
        vin:                    { type: [string, 'null'] }
        transmission:           { type: [string, 'null'] }
        engine_power_kw:        { type: [integer, 'null'] }
        engine_displacement_ccm: { type: [integer, 'null'] }
        vehicle_weight_kg:      { type: [integer, 'null'] }
        engine_category:        { type: [string, 'null'] }
        registered_at:          { type: [string, 'null'], format: date-time }
        comments:               { type: [string, 'null'] }
        customer_id:            { type: [string, 'null'] }
        km:                     { type: [number, 'null'] }
        km_registered_at:       { type: [string, 'null'], format: date-time }
        last_inspection_at:     { type: [string, 'null'], format: date-time }
        next_inspection_at:     { type: [string, 'null'], format: date-time }
        custom_fields:
          type: [object, 'null']
          additionalProperties: true
          description: |
            Free-form key/value blob. The API treats this as opaque
            and stores it as a single JSON column.
      additionalProperties: false

    VehiclePatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/vehicles/{id}`. Every field is optional on
        the wire and carries three-state semantics:

        - Field omitted → leave the entity field untouched.
        - Field present with value `null` → clear the entity field.
        - Field present with a value → set the entity field.

        Fields not listed here are intentionally NOT patchable:
        `id`, `company_id`, `created_by`, system timestamps
        (`created_at`, `updated_at`, `deleted_at`), rental fields
        (`is_rental`, `rental_*`), and computed/joined fields
        (`next_appointment`, `manufacturer_name`, `customer_name`).

        `model` and `manufacturer_id` are NOT NULL at the entity
        layer; sending either as explicit `null` is rejected with
        422 `parameter_invalid`.

        `custom_fields` is replaced wholesale — per-key merging is
        not supported.
      properties:
        manufacturer_id:        { type: string }
        model:                  { type: string }
        license:                { type: [string, 'null'] }
        variant:                { type: [string, 'null'] }
        vin:                    { type: [string, 'null'] }
        transmission:           { type: [string, 'null'] }
        engine_power_kw:        { type: [integer, 'null'] }
        engine_displacement_ccm: { type: [integer, 'null'] }
        vehicle_weight_kg:      { type: [integer, 'null'] }
        engine_category:        { type: [string, 'null'] }
        registered_at:          { type: [string, 'null'], format: date-time }
        comments:               { type: [string, 'null'] }
        customer_id:            { type: [string, 'null'] }
        km:                     { type: [number, 'null'] }
        km_registered_at:       { type: [string, 'null'], format: date-time }
        last_inspection_at:     { type: [string, 'null'], format: date-time }
        next_inspection_at:     { type: [string, 'null'], format: date-time }
        custom_fields:
          type: [object, 'null']
          additionalProperties: true
      additionalProperties: false

    # ─── Worksheet requests ───

    WorksheetCreateRequest:
      type: object
      required: [customer_id]
      description: |
        Body of `POST /v1/worksheets`. Only client-supplied fields
        appear here — server-managed columns (id, company_id,
        created_by, seq_number, status, all timestamps, totals)
        are filled in automatically.

        Line items are NOT accepted on creation; a created worksheet
        starts with an empty `line_items` array.
      properties:
        customer_id:       { type: string }
        vehicle_id:        { type: [string, 'null'] }
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        assigned_to:       { type: [string, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
      additionalProperties: false

    WorksheetPatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/worksheets/{id}`. Every field carries
        three-state semantics:

        - Field omitted → leave the entity field untouched.
        - Field present with value `null` → clear the entity field.
        - Field present with a value → set the entity field.

        Fields not listed here are intentionally NOT patchable:
        `id`, `company_id`, `created_by`, `seq_number`, `status`
        (use the transitions endpoint), `customer_id`, `vehicle_id`,
        all totals, all `*_at` timestamps, `line_items`, `documents`.
      properties:
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        assigned_to:       { type: [string, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
      additionalProperties: false

    WorksheetTransitionRequest:
      type: object
      required: [action]
      description: |
        Body of `POST /v1/worksheets/{id}/transitions`. The server
        runs the matching state transition and stamps the
        corresponding `*_at` column.
      properties:
        action:
          type: string
          enum: [draft, waiting, started, completed, delivered, archived]
      additionalProperties: false

    # ─── Line item batch mutation (worksheets & quotes) ───

    LineItemInput:
      type: object
      required: [description, quantity, unit_type, unit_price, vat_code]
      description: |
        A line item supplied by the client in an `add` or `update`
        operation. `tax_rate` is NOT accepted — it is resolved
        server-side from `vat_code` so the stored rate always matches
        the company VAT table. Money/quantity fields are in the working
        currency unit and natural units respectively.
      properties:
        description:        { type: string, description: 'Required, non-empty.' }
        quantity:           { type: number, description: 'Required, > 0.' }
        unit_type:          { type: string, description: 'Required (e.g. "pz", "kg", "L", "kit").' }
        unit_price:         { type: number, description: 'Required, >= 0.' }
        unit_discount_rate: { type: number, description: 'Percent, 0..100. Defaults to 0.' }
        vat_code:           { type: string, description: 'Required; must exist in the company VAT table.' }
        article_id:
          type: [string, 'null']
          description: |
            Optional link to a catalog article. When set it must
            reference an article owned by the caller's company. On
            worksheets, article-backed lines move inventory stock.
        position:
          type: [integer, 'null']
          description: |
            Optional ordering position. When omitted the item is
            appended after the current maximum position.
      additionalProperties: false

    LineItemUpdate:
      description: |
        Identifies an existing line item by `id` and carries its full
        replacement fields (a full replace of editable fields, not a
        partial patch). Applied as a remove-then-add internally so
        article stock reconciles through the normal path; as a result
        the updated line receives a NEW id in the response.
      allOf:
        - { $ref: '#/components/schemas/LineItemInput' }
        - type: object
          required: [id]
          properties:
            id: { type: string }

    LineItemReorder:
      type: object
      required: [id, position]
      description: |
        Sets the position of an existing line item that is not otherwise
        added, updated, or removed in the same batch.
      properties:
        id:       { type: string }
        position: { type: integer }
      additionalProperties: false

    LineItemsBatchRequest:
      type: object
      description: |
        Body of `POST /v1/worksheets/{id}/line-items/batch` and
        `POST /v1/quotes/{id}/line-items/batch`. Applies a single diff
        against the resource's current line-item set; totals are
        recomputed server-side from the result and the full resource is
        returned.

        All four arrays are optional; an empty body is a no-op. An id may
        appear in at most one of `remove`/`update`/`reorder` — listing it
        twice is a 422. Ids in `remove`/`update`/`reorder` must belong to
        the target resource.
      properties:
        add:
          type: array
          items: { $ref: '#/components/schemas/LineItemInput' }
        update:
          type: array
          items: { $ref: '#/components/schemas/LineItemUpdate' }
        remove:
          type: array
          description: Ids of line items to delete.
          items: { type: string }
        reorder:
          type: array
          items: { $ref: '#/components/schemas/LineItemReorder' }
      additionalProperties: false

    # ─── Quote requests ───

    QuoteCreateRequest:
      type: object
      required: [customer_id]
      description: |
        Body of `POST /v1/quotes`. Server-managed columns (id,
        company_id, created_by, seq_number, status, all timestamps,
        totals) are filled in automatically. Line items are not
        accepted on creation.
      properties:
        customer_id:       { type: string }
        vehicle_id:        { type: [string, 'null'] }
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
      additionalProperties: false

    QuotePatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/quotes/{id}`. Three-state semantics
        (omitted = leave unchanged, explicit null = clear, concrete
        value = set). `status`, `seq_number`, `customer_id`,
        `vehicle_id`, totals, `worksheet_id`, and timestamps are
        NOT patchable.
      properties:
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
      additionalProperties: false

    QuoteTransitionRequest:
      type: object
      required: [action]
      description: |
        Body of `POST /v1/quotes/{id}/transitions`. Unknown action
        values are rejected with 422 `parameter_invalid`.
      properties:
        action:
          type: string
          enum: [waiting, accepted, rejected]
      additionalProperties: false

    # ─── Invoice requests ───

    InvoiceCreateRequest:
      type: object
      required: [worksheet_id, payment_method]
      description: |
        Body of `POST /v1/invoices`. A mobile invoice is always
        created from an existing worksheet — there is no "blank
        invoice + add lines" flow on this tier.
      properties:
        worksheet_id:
          type: string
          description: Source worksheet UUID (must belong to caller's company).
        payment_method:
          type: string
          description: Free-form payment method label.
          minLength: 1
        payment_due_at:
          type: [string, 'null']
          format: date-time
          description: |
            Optional. RFC 3339. Defaults to today + the company's
            configured payment term.
      additionalProperties: false

    InvoicePatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/invoices/{id}`. Three-state per field:
        omitted (leave alone), `null` (clear, where nullable),
        concrete value (set). Non-nullable columns reject `null`
        with 422 `parameter_invalid`.

        Fields not listed here are intentionally NOT patchable:
        `id`, `seq_number`, `status`, `customer_id`, `vehicle_id`,
        `worksheet_id`, all totals, all timestamps except
        `payment_due_at`. Line item add/remove is not exposed.

        `title`, `comments`, `internal_comments` are deliberately
        absent today: the service-layer Update contract doesn't
        carry these columns yet, so accepting them on the wire
        would silently drop the write. They will be added once
        the service is extended.
      properties:
        payment_method:     { type: string }
        payment_condition:  { type: string }
        payment_due_at:     { type: string, format: date-time }
        vat_exigibility:    { type: string }
        sezionale_id:       { type: [string, 'null'] }
        pa_document_type:   { type: [string, 'null'] }
        pa_document_number: { type: [string, 'null'] }
        pa_document_date:   { type: [string, 'null'], format: date-time }
        pa_cig:             { type: [string, 'null'] }
        pa_cup:             { type: [string, 'null'] }
      additionalProperties: false

    InvoiceIssueRequest:
      type: object
      description: |
        Body of `POST /v1/invoices/{id}/issue`. All fields are
        optional; an empty body uses server defaults.
      properties:
        issued_at:
          type: [string, 'null']
          format: date-time
          description: |
            Override the server clock. Back-dates that would violate
            the monotonic per-company sequence return 409.
      additionalProperties: false

    # ─── Credit note requests ───

    CreditNoteCreateRequest:
      type: object
      required: [invoice_id]
      description: |
        Body of `POST /v1/credit-notes`. Credit notes are derivative
        documents — created from an existing invoice. The source
        invoice MUST belong to the authenticated user's company.

        Server-derived / immutable fields are NOT accepted: `id`,
        `company_id` (from source invoice), `created_by` (from
        auth), `seq_number` (server-assigned), totals (copied from
        invoice), timestamps.
      properties:
        invoice_id:
          type: string
          description: Source invoice UUID (must belong to caller's company).
        title:        { type: [string, 'null'] }
        sezionale_id: { type: [string, 'null'] }
      additionalProperties: false

    CreditNotePatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/credit-notes/{id}`. `title` is the ONLY
        mutable field on a credit note. All other fields are derived
        from the source invoice, immutable identity, or
        server-managed.

        Three-state for `title`:

        - Omitted → leave unchanged.
        - Explicit JSON `null` → clear (SQL NULL).
        - Concrete value → set.

        Sending any other key returns 400 `malformed_request`.
      properties:
        title: { type: [string, 'null'] }
      additionalProperties: false

    # ─── Checkin requests ───

    CheckinCreateRequest:
      type: object
      required: [customer_id, vehicle_id]
      description: |
        Body of `POST /v1/checkins`. Server-managed fields (`id`,
        `company_id`, `created_by`, `seq_number`, `status`,
        timestamps) are NOT accepted. Line items start empty — a
        dedicated sub-resource will land later.
      properties:
        customer_id:       { type: string }
        vehicle_id:        { type: string }
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        fuel_level:        { type: [integer, 'null'] }
        section1:          { type: [string, 'null'] }
        section2:          { type: [string, 'null'] }
        section3:          { type: [string, 'null'] }
        section4:          { type: [string, 'null'] }
        section5:          { type: [string, 'null'] }
        section6:          { type: [string, 'null'] }
        section7:          { type: [string, 'null'] }
        section8:          { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
      additionalProperties: false

    CheckinPatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/checkins/{id}`. Three-state per field:

        - Field omitted → leave the entity field untouched.
        - Field present with value `null` → clear the entity field.
        - Field present with a value → set the entity field.

        Fields not listed here are intentionally NOT patchable: `id`,
        `company_id`, `created_by`, `seq_number`, system timestamps,
        `status` (use the transitions endpoint), `customer_id`,
        `vehicle_id` (immutable), and `line_items` (separate
        sub-resource).
      properties:
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments: { type: [string, 'null'] }
        fuel_level:        { type: [integer, 'null'] }
        section1:          { type: [string, 'null'] }
        section2:          { type: [string, 'null'] }
        section3:          { type: [string, 'null'] }
        section4:          { type: [string, 'null'] }
        section5:          { type: [string, 'null'] }
        section6:          { type: [string, 'null'] }
        section7:          { type: [string, 'null'] }
        section8:          { type: [string, 'null'] }
        km:                { type: [number, 'null'] }
        work_category_id:  { type: [string, 'null'] }
        worksheet_id:      { type: [string, 'null'] }
        quote_id:          { type: [string, 'null'] }
      additionalProperties: false

    CheckinTransitionRequest:
      type: object
      required: [action]
      description: |
        Body of `POST /v1/checkins/{id}/transitions`. The target
        lifecycle state. Unknown values are rejected with 422
        `parameter_invalid`.
      properties:
        action:
          type: string
          enum: [active, completed]
      additionalProperties: false

    # ─── Checkin line item batch ───

    CheckinLineItemInput:
      type: object
      required: [description]
      description: |
        A checkin line item supplied in an `add` or `update`. Checkin
        line items are description-only notes — no quantity, price, VAT,
        article, or position.
      properties:
        description: { type: string, description: 'Required, non-empty.' }
      additionalProperties: false

    CheckinLineItemUpdate:
      description: |
        Identifies an existing checkin line item by `id` and carries its
        replacement description. Applied as a remove-then-add internally,
        so the updated line receives a NEW id in the response.
      allOf:
        - { $ref: '#/components/schemas/CheckinLineItemInput' }
        - type: object
          required: [id]
          properties:
            id: { type: string }

    CheckinLineItemsBatchRequest:
      type: object
      description: |
        Body of `POST /v1/checkins/{id}/line-items/batch`. Applies a
        single diff against the checkin's current line items and returns
        the full checkin. Narrower than the worksheet/quote batch: no
        `reorder` (rows have no position), description-only fields.

        All arrays are optional; an empty body is a no-op. An id may
        appear in at most one of `remove`/`update`, and ids in
        `remove`/`update` must belong to the target checkin.
      properties:
        add:
          type: array
          items: { $ref: '#/components/schemas/CheckinLineItemInput' }
        update:
          type: array
          items: { $ref: '#/components/schemas/CheckinLineItemUpdate' }
        remove:
          type: array
          description: Ids of line items to delete.
          items: { type: string }
      additionalProperties: false

    # ─── Calendar entry requests ───

    CalendarEntryCreateRequest:
      type: object
      required: [title, start_time, end_time, color]
      description: |
        Body of `POST /v1/calendar-entries`. `end_time` must be
        strictly after `start_time`. `user_id` (ownership),
        `company_id`, and audit timestamps are server-managed and
        not accepted on the wire.

        `worksheet_id` / `quote_id` / `checkin_id` are response-only:
        the underlying repository does not persist them on create
        today, so they are intentionally absent from this request.
      properties:
        title:                 { type: string }
        description:           { type: [string, 'null'] }
        start_time:            { type: string, format: date-time }
        end_time:              { type: string, format: date-time }
        color:                 { type: string }
        email_reminder:        { type: boolean }
        customer_id:           { type: [string, 'null'], format: uuid }
        vehicle_id:            { type: [string, 'null'], format: uuid }
        work_category_id:      { type: [string, 'null'], format: uuid }
        calendar_id:           { type: [string, 'null'], format: uuid }
        auto_reminder_enabled: { type: boolean }
        # email | sms | whatsapp. Required when auto_reminder_enabled;
        # sms/whatsapp validate the customer's mobile and charge credits
        # at save time (email is free). Enabling also requires a future
        # start_time and a customer with the channel's contact field.
        auto_reminder_channel:
          type: [string, 'null']
          enum: [email, sms, whatsapp, null]
      additionalProperties: false

    CalendarEntryPatchRequest:
      type: object
      description: |
        Body of `PATCH /v1/calendar-entries/{id}`. Three-state PATCH
        semantics: omitted = unchanged, explicit `null` = clear (only
        for nullable fields). Sending `null` on a non-nullable field
        (`title`, `color`, `start_time`, `end_time`,
        `email_reminder`, `auto_reminder_enabled`) returns 422
        `parameter_invalid`. When `start_time` or `end_time` is
        patched the resulting pair must satisfy
        `end_time > start_time`.
      properties:
        title:                 { type: string }
        description:           { type: [string, 'null'] }
        start_time:            { type: string, format: date-time }
        end_time:              { type: string, format: date-time }
        color:                 { type: string }
        email_reminder:        { type: boolean }
        customer_id:           { type: [string, 'null'], format: uuid }
        vehicle_id:            { type: [string, 'null'], format: uuid }
        work_category_id:      { type: [string, 'null'], format: uuid }
        calendar_id:           { type: [string, 'null'], format: uuid }
        auto_reminder_enabled: { type: boolean }
        # email | sms | whatsapp. Required when auto_reminder_enabled;
        # sms/whatsapp validate the customer's mobile and charge credits
        # at save time (email is free). Enabling also requires a future
        # start_time and a customer with the channel's contact field.
        auto_reminder_channel:
          type: [string, 'null']
          enum: [email, sms, whatsapp, null]
      additionalProperties: false

    # ─── Auth responses ───

    AuthMethodResponse:
      type: object
      required: [object, email, method]
      properties:
        object:
          type: string
          const: auth_method
          description: Polymorphic discriminator.
        email:
          type: string
          format: email
          description: Echoes the email from the request.
        method:
          type: string
          enum: [password, sign_on_token, not_found]
          description: |
            Credential step to show next. `password` → submit to
            `/v1/auth/login`; `sign_on_token` → request a code via
            `/v1/auth/sign-on-token`, then submit to
            `/v1/auth/login-with-token`; `not_found` → no account for
            this email, show the contact-assistance sheet (web-parity
            disclosure — the public login already reveals this).

    AuthTokenResponse:
      type: object
      required: [object, token_type, access_token, expires_at, user]
      properties:
        object:
          type: string
          const: auth_token
          description: Polymorphic discriminator.
        token_type:
          type: string
          const: Bearer
        access_token:
          type: string
          description: |
            Mobile JWT. Send as `Authorization: Bearer <access_token>`
            on subsequent requests. Carries `aud=mobile`.
          example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
        expires_at:
          type: string
          format: date-time
          description: |
            Absolute expiration timestamp in ISO 8601 / RFC 3339 UTC.
            Clients should refresh a few minutes before this elapses.
          example: '2026-06-05T00:00:00Z'
        user:
          $ref: '#/components/schemas/User'

    SignOnTokenResponse:
      type: object
      required: [object, email, message]
      properties:
        object:
          type: string
          const: sign_on_token_request
        email:
          type: string
          format: email
          description: Echoes the email from the request.
        message:
          type: string
          description: Human-readable confirmation string.

    # ─── Resource: User ───

    User:
      type: object
      required: [object, id, name, email]
      properties:
        object:
          type: string
          const: user
        id:
          type: string
          description: Stable opaque ID (UUID at the entity layer).
          example: 9921d5f7-83ba-4c60-a1e2-cf7b4d0925a8
        name:
          type: string
          description: Display name (first + last joined).
        email:
          type: string
          format: email
        role:
          type: string
          description: |
            Workspace role. Omitted for users without a role set.

    # ─── Resource: Customer ───

    Customer:
      type: object
      required: [object, id, customer_type, created_at, updated_at]
      description: |
        A workshop's client. Multi-tenant: belongs to one company.
        Most descriptive fields are optional (nullable) because
        customer records are commonly created from a phone call
        with only a name and phone number.
      properties:
        object:
          type: string
          const: customer
        id:
          type: string
          example: 4c911f08-7713-4e83-bdf6-a0692a1b43fd
        customer_type:
          type: string
          description: Tax-status discriminator (e.g. "private", "company").
        full_name:        { type: [string, 'null'] }
        email:            { type: [string, 'null'], format: email }
        phone:
          type: [string, 'null']
          description: Stored E.164 value, e.g. "+393331234567".
        phone_dial_code:
          type: [string, 'null']
          description: |
            `phone` split server-side for the edit form's dial-code
            selector (e.g. "+39"). Null when `phone` is null.
        phone_national:
          type: [string, 'null']
          description: |
            `phone` minus the dial code, for the edit form's
            national-number input. Null when `phone` is null.
        address:          { type: [string, 'null'] }
        city:             { type: [string, 'null'] }
        zip_code:         { type: [string, 'null'] }
        province:         { type: [string, 'null'] }
        tax_id:
          type: [string, 'null']
          description: Italian VAT number (partita IVA) for companies.
        fiscal_code:
          type: [string, 'null']
          description: Italian fiscal code (codice fiscale) for individuals.
        pec:
          type: [string, 'null']
          description: Italian certified-email address for e-invoicing.
        destination_code:
          type: [string, 'null']
          description: Italian SDI destination code for e-invoicing.
        payment_method:   { type: [string, 'null'] }
        comments:         { type: [string, 'null'] }
        birthday:
          type: [string, 'null']
          format: date-time
        last_vehicle_name:
          type: [string, 'null']
          description: |
            Display string of the customer's most recent vehicle
            (e.g. "Fiat Panda"). Pre-computed on the entity for
            list-row rendering without a second request.
        last_vehicle_license: { type: [string, 'null'] }
        total_spent:
          type: number
          format: double
          description: |
            Lifetime spend (worksheet totals net of discounts), computed
            by the list query for row rendering. Money — render via the
            locale formatter; avoid arithmetic.
        documents:
          type: array
          description: |
            Attachments (Documenti Allegati). Present on the detail
            response only; omitted on lists.
          items: { $ref: '#/components/schemas/CustomerDocument' }
        next_appointment:
          type: [string, 'null']
          format: date-time
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated on the response from
            DELETE /v1/customers/{id}. Customers with a non-null
            deleted_at are not returned by list/detail endpoints —
            this field appears for the delete response only.

    # ─── Report widgets ───

    ReportWidgetEnvelope:
      type: object
      required: [object, widget, preset, from, to, data]
      description: |
        Shell of every report-widget response. `preset`/`from`/`to` echo
        the effective sticky date filter (view key `report`, shared with
        the web report page); `from`/`to` are always concrete dates —
        `all` resolves to company-creation → today. `data` carries the
        widget-specific shape (one of the Report* schemas below, keyed
        by the widget path parameter). New widgets add a key and a Data
        schema; the envelope never changes.
      properties:
        object: { type: string, const: report_widget }
        widget: { type: string }
        preset: { type: string }
        from:   { type: string, format: date }
        to:     { type: string, format: date }
        data:
          oneOf:
            - $ref: '#/components/schemas/ReportKPIs'
            - $ref: '#/components/schemas/ReportChart'
            - $ref: '#/components/schemas/ReportQuoteConversion'
            - $ref: '#/components/schemas/ReportWorksheetStatus'
            - $ref: '#/components/schemas/ReportOutstandingWorksheets'
            - $ref: '#/components/schemas/ReportAppointments'
            - $ref: '#/components/schemas/ReportRankedList'

    ReportKPIs:
      type: object
      required: [kpis]
      description: Data of widget `kpis`.
      properties:
        kpis:
          type: array
          items:
            type: object
            required: [key, value, is_currency, previous_value, delta, delta_percentage]
            properties:
              key:              { type: string, enum: [sales, outstanding, worksheets, customers] }
              value:            { type: number }
              is_currency:      { type: boolean }
              previous_value:   { type: number }
              delta:            { type: number }
              delta_percentage: { type: number }

    ReportChart:
      type: object
      required: [x_axis, series]
      description: |
        Data of the stacked-chart widgets (`sales-chart`,
        `expenses-chart`, `worksheet-margin-chart`). `x_axis` entries
        are YYYY-MM-DD bucket starts (daily ≤31-day periods, else
        monthly); each series' `values` align with `x_axis`.
      properties:
        x_axis:
          type: array
          items: { type: string }
        series:
          type: array
          items:
            type: object
            required: [key, values]
            properties:
              key:    { type: string }
              values: { type: array, items: { type: number } }

    ReportQuoteConversion:
      type: object
      required: [sent, rejected, converted, conversion_rate]
      description: Data of widget `quote-conversion`.
      properties:
        sent:            { type: integer }
        rejected:        { type: integer }
        converted:       { type: integer }
        conversion_rate: { type: number }

    ReportWorksheetStatus:
      type: object
      required: [draft, waiting, started, completed, delivered, completion_rate]
      description: Data of widget `worksheet-status`.
      properties:
        draft:           { type: integer }
        waiting:         { type: integer }
        started:         { type: integer }
        completed:       { type: integer }
        delivered:       { type: integer }
        completion_rate: { type: number }

    ReportOutstandingWorksheets:
      type: object
      required: [worksheets]
      description: Data of widget `outstanding-worksheets`.
      properties:
        worksheets:
          type: array
          items:
            type: object
            required: [worksheet_id, title, customer_name, outstanding, total, bar_width]
            properties:
              worksheet_id:  { type: string }
              title:         { type: string }
              customer_name: { type: string }
              outstanding:   { type: number }
              total:         { type: number }
              bar_width:
                type: number
                description: Server-normalized bar length, 0-100.

    ReportAppointments:
      type: object
      required: [appointments]
      description: Data of widget `upcoming-appointments`.
      properties:
        appointments:
          type: array
          items:
            type: object
            required: [id, title, customer_name, start_time]
            properties:
              id:            { type: string }
              title:         { type: string }
              customer_name: { type: string }
              start_time:    { type: string, format: date-time }

    ReportRankedList:
      type: object
      required: [rows]
      description: |
        Data of the ranked-list widgets (`top-customers`,
        `top-services`, `worksheet-margins`). `amount` carries the sort
        metric; `bar_width` is server-normalized 0-100. The margins
        widget also fills `sublabel` (document number) and `percent`.
      properties:
        rows:
          type: array
          items:
            type: object
            required: [id, label, amount, bar_width]
            properties:
              id:        { type: string }
              label:     { type: string }
              sublabel:  { type: string }
              amount:    { type: number }
              percent:   { type: number }
              bar_width: { type: number }

    BillingInfo:
      type: object
      required: [object, state, is_legacy_plan, cancel_at_period_end, unlimited_credits]
      properties:
        object:
          type: string
          const: billing_info
        state:
          type: string
          enum: [trial, trial_expired, active, payment_processing, payment_issue]
        plan_name:            { type: [string, 'null'] }
        is_legacy_plan:       { type: boolean }
        billing_interval:     { type: [string, 'null'], enum: [monthly, yearly, null] }
        trial_ends_at:        { type: [string, 'null'], format: date-time }
        current_period_end:   { type: [string, 'null'], format: date-time }
        cancel_at_period_end: { type: boolean }
        credit_balance:       { type: [number, 'null'] }
        unlimited_credits:    { type: boolean }

    SupportChannel:
      type: object
      description: |
        One support channel's schedule and current availability. Labels
        arrive display-ready in Italian; `open_now` is evaluated in Rome
        time at request time, so the app renders availability badges
        without owning the schedule.
      required: [schedule_label, open_now, response_time]
      properties:
        schedule_label: { type: string, example: 'Dal lunedì al sabato dalle 9:00 alle 21:00' }
        open_now:       { type: boolean }
        response_time:  { type: string, example: '30 minuti' }

    SupportInfo:
      type: object
      required:
        [object, agent_name, email, whatsapp_number, email_channel,
         whatsapp_channel, team_note, priority_support]
      properties:
        object:
          type: string
          const: support_info
        agent_name:      { type: string }
        email:           { type: string }
        whatsapp_number: { type: string, description: 'Display form, e.g. "+39 351 761 1637".' }
        email_channel:   { $ref: '#/components/schemas/SupportChannel' }
        whatsapp_channel:
          allOf: [{ $ref: '#/components/schemas/SupportChannel' }]
          description: |
            `open_now` covers calls — outside the window messages still
            work (badge "Solo messaggi").
        team_note:
          type: string
          description: Ready-made availability footer for the support hub.
        priority_support: { type: boolean }

    Calendar:
      type: object
      required: [object, id, name, default_color]
      description: |
        A calendar container grouping entries (e.g. Riparazioni,
        Revisioni). Entries carry their own `color`; `default_color`
        is the calendar's colour used for its filter dot and as the
        default for new entries.
      properties:
        object:
          type: string
          const: calendar
        id:            { type: string }
        name:          { type: string }
        default_color: { type: string }

    Manufacturer:
      type: object
      required: [object, id, name]
      description: |
        A vehicle make from the manufacturer catalog (global rows plus
        company-specific additions). Read-only on this tier.
      properties:
        object:
          type: string
          const: manufacturer
        id:   { type: string }
        name: { type: string }

    Article:
      type: object
      required: [object, id, code, name, kind, unit_type, stock, sale_price, vat_code, unit_discount_rate]
      description: |
        A catalog row backing both the magazzino (parts) and the listino
        manodopera (labor) — `kind` discriminates the two from the unit
        type. `sale_price` is the NET unit price (VAT excluded), with
        the customer's price-list override applied when the request
        carried a `customer_id`; feed it straight into a line item's
        `unit_price`.
      properties:
        object:
          type: string
          const: article
        id:   { type: string, format: uuid }
        code: { type: [string, 'null'] }
        name: { type: string }
        kind:
          type: string
          enum: [item, work]
          description: '"item" = magazzino (kg, L, kit, pz); "work" = manodopera (gg, h, lav, min).'
        unit_type:          { type: string }
        stock:              { type: number }
        sale_price:         { type: [number, 'null'] }
        vat_code:           { type: string }
        unit_discount_rate: { type: [integer, 'null'] }

    SendContext:
      type: object
      required: [object, email, sms, whatsapp, credit_balance]
      description: |
        Defaults for the Invia al cliente sheet (quotes and worksheets):
        recipients, precompiled bodies (share link included), the
        read-only WhatsApp template preview, per-channel credit costs
        (0 = included in the plan) and the current balance.

        SMS/WhatsApp recipients are Italian-only: when the stored phone
        is empty or +39 the client shows an editable +39 national-number
        field (the typed number is backfilled onto the customer on a
        successful send when previously empty); a stored non-+39 number
        renders read-only with the send disabled (web parity).
      properties:
        object: { type: string, enum: [quote_send_context, worksheet_send_context] }
        email:
          type: object
          required: [recipient, subject, body, senders]
          properties:
            recipient: { type: string }
            subject:   { type: string }
            body:      { type: string }
            senders:
              type: array
              description: |
                The web "Mittente" dropdown options: internal always;
                smtp/gmail when the company configured them (in the web
                settings — configuration is not exposed here). Exactly
                one entry has default=true.
              items:
                type: object
                required: [key, label, default]
                properties:
                  key:     { type: string, enum: [internal, smtp, gmail] }
                  label:   { type: string, example: 'Account Gmail (x@gmail.com)' }
                  default: { type: boolean }
        sms:
          type: object
          required: [recipient, body, max_length, credit_cost]
          properties:
            recipient:   { type: string }
            body:        { type: string }
            max_length:  { type: integer, const: 160 }
            credit_cost: { type: number }
        whatsapp:
          type: object
          required: [recipient, template_preview, credit_cost]
          properties:
            recipient:        { type: string }
            template_preview: { type: string, description: 'Pre-approved template — display-only.' }
            credit_cost:      { type: number }
        credit_balance: { type: number }

    SendRequest:
      type: object
      required: [channel, recipient]
      properties:
        channel:   { type: string, enum: [email, sms, whatsapp] }
        recipient: { type: string }
        subject:   { type: string, maxLength: 50, description: 'email only (required there).' }
        message:   { type: string, description: 'email/sms body; ignored for whatsapp.' }
        sender_method:
          type: string
          enum: [internal, smtp, gmail]
          description: |
            email only; omitted = company default. A missing or broken
            smtp/gmail configuration falls back to the internal sender
            (web parity).

    SendResult:
      type: object
      required: [object, channel, recipient, credits_spent, credit_balance]
      properties:
        object:         { type: string, enum: [quote_send, worksheet_send] }
        channel:        { type: string, enum: [email, sms, whatsapp] }
        recipient:      { type: string }
        credits_spent:  { type: number }
        credit_balance: { type: number }

    CustomerMessage:
      type: object
      required: [object, id, channel, title, preview, content, recipient, sent_at]
      description: One entry of the document's send log (Messaggi Cliente).
      properties:
        object:    { type: string, const: customer_message }
        id:        { type: string }
        channel:   { type: string, enum: [email, sms, whatsapp] }
        title:     { type: [string, 'null'], description: 'Email subject; null otherwise.' }
        preview:   { type: string }
        content:   { type: string }
        recipient: { type: string }
        sent_at:   { type: string, format: date-time }

    ShareLink:
      type: object
      required: [object, url]
      description: |
        Public customer-facing URL of a document. Get-or-create, so the
        URL is stable across calls; append it to the message when sending
        via a device channel (WhatsApp / SMS / email deep links).
      properties:
        object:
          type: string
          const: share_link
        url: { type: string, example: 'https://officina.it/dl/aB3xK9' }

    Sezionale:
      type: object
      required: [object, id, code, label]
      properties:
        object: { type: string, const: sezionale }
        id: { type: string, format: uuid }
        code: { type: string }
        label: { type: string }

    WorkCategory:
      type: object
      required: [object, id, name]
      properties:
        object: { type: string, const: work_category }
        id: { type: string, format: uuid }
        name: { type: string }

    VatRate:
      type: object
      required: [object, code, rate, description]
      description: |
        A VAT rate enabled for the company. `code` is what line items
        reference via `vat_code`; `rate` is for display and client-side
        total previews — the server always re-resolves the rate from
        the code when persisting.
      properties:
        object:
          type: string
          const: vat_rate
        code:        { type: string }
        rate:        { type: number }
        description: { type: [string, 'null'] }

    # ─── Resource: Vehicle ───

    Vehicle:
      type: object
      required:
        - object
        - id
        - manufacturer_id
        - manufacturer_name
        - model
        - is_rental
        - created_at
        - updated_at
      description: |
        A vehicle owned (or managed) by a workshop client.
        Multi-tenant: belongs to one company. `manufacturer_name`
        and `customer_name` are joined-in convenience fields
        populated by the repository so list rows can render
        `Fiat Panda — Mario Rossi` without a follow-up request.

        Rental fields are read-only on this resource. Mutate them
        via the dedicated rental setup flow.
      properties:
        object:
          type: string
          const: vehicle
        id:
          type: string
          example: 2200ab13-7c48-4d52-b6e9-0a4f81c27d3e
        license:
          type: [string, 'null']
          description: License plate (regional format, free-form string).
        manufacturer_id: { type: string }
        manufacturer_name:
          type: string
          description: |
            Joined from the manufacturers table for display.
            Read-only; mutate via `manufacturer_id`.
        model:           { type: string }
        variant:         { type: [string, 'null'] }
        vin:
          type: [string, 'null']
          description: Vehicle Identification Number.
        transmission:           { type: [string, 'null'] }
        engine_power_kw:        { type: [integer, 'null'] }
        engine_displacement_ccm: { type: [integer, 'null'] }
        vehicle_weight_kg:      { type: [integer, 'null'] }
        engine_category:        { type: [string, 'null'] }
        registered_at:          { type: [string, 'null'], format: date-time }
        comments:               { type: [string, 'null'] }
        customer_id:            { type: [string, 'null'] }
        customer_name:
          type: [string, 'null']
          description: |
            Joined from the owning customer's `full_name` for
            display. Read-only; mutate via `customer_id`.
        km:
          type: [number, 'null']
          description: Most recently recorded odometer reading.
        km_registered_at:   { type: [string, 'null'], format: date-time }
        last_inspection_at: { type: [string, 'null'], format: date-time }
        next_inspection_at: { type: [string, 'null'], format: date-time }
        next_appointment:
          type: [string, 'null']
          format: date-time
          description: |
            Earliest upcoming calendar appointment for this vehicle,
            computed at read time.
        custom_fields:
          type: [object, 'null']
          additionalProperties: true
          description: Free-form key/value blob stored as one JSON column.
        custom_field_values:
          type: array
          description: |
            Detail responses only. The company's visible custom field
            definitions (settings → Campi veicolo) resolved against
            this vehicle: configured order, display-ready names, ""
            when the vehicle has no value. Clients render these
            directly — no definition lookup needed.
          items:
            type: object
            required: [id, name, type, value]
            properties:
              id:    { type: string }
              name:  { type: string }
              type:  { type: string, enum: [string, integer, datetime] }
              value: { type: string }
        documents:
          type: array
          description: |
            Detail responses only — the vehicle's Documenti Allegati.
            `url` is the authenticated download route
            (`/v1/vehicles/{id}/documents/{documentID}/download`).
          items: { $ref: '#/components/schemas/VehicleDocument' }
        field_visibility:
          type: object
          description: |
            Detail responses only. The company's optional-field
            toggles (settings → Campi veicolo): the edit form hides —
            and omits from its PATCH — exactly what the web form
            hides. All true when never configured.
          properties:
            km:                      { type: boolean }
            numero_telaio:           { type: boolean }
            transmission:            { type: boolean }
            engine_category:         { type: boolean }
            engine_power_kw:         { type: boolean }
            engine_displacement_ccm: { type: boolean }
            vehicle_weight_kg:       { type: boolean }
            registration_date:       { type: boolean }
            last_inspection_date:    { type: boolean }
            next_inspection_date:    { type: boolean }
        is_rental:
          type: boolean
          description: |
            Whether this vehicle is part of the workshop's rental
            fleet. Read-only here — managed by the rental setup flow.
        rental_daily_price_before_tax: { type: [number, 'null'] }
        rental_daily_price_tax:        { type: [number, 'null'] }
        rental_daily_price_after_tax:  { type: [number, 'null'] }
        rental_vat_code:               { type: [string, 'null'] }
        rental_daily_discount_rate:    { type: [number, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated on the response from
            DELETE /v1/vehicles/{id}. Vehicles with a non-null
            deleted_at are not returned by list/detail endpoints —
            this field appears for the delete response only.

    # ─── Resource: Worksheet ───

    Worksheet:
      type: object
      required:
        - object
        - id
        - seq_number
        - status
        - customer_id
        - created_by
        - created_at
        - updated_at
      description: |
        A workshop work order. Belongs to one customer and (usually)
        one vehicle. Lifecycle: draft → waiting → started →
        completed → delivered → archived. Each state has a matching
        nullable `*_at` timestamp populated by the server when the
        transition fires.

        Totals are server-computed from the embedded line items and
        rounded to the working currency precision. They are
        read-only on the wire.

        `line_items` is populated on detail / write responses and
        omitted (or `null`) on list responses to keep page payloads
        small.
      properties:
        object:
          type: string
          const: worksheet
        id:
          type: string
          example: wks_91247
        seq_number:
          type: integer
          description: |
            Per-company human-friendly sequence number. Allocated
            server-side at creation; never reused.
        status:
          type: string
          enum: [draft, waiting, started, completed, delivered, archived]
        title:           { type: [string, 'null'] }
        comments:        { type: [string, 'null'] }
        internal_comments:
          type: [string, 'null']
          description: |
            Workshop-internal notes; surfaced through the mobile
            app for the workshop's own users (no customer-facing
            channel exposes this field).
        customer_id:      { type: string }
        vehicle_id:       { type: [string, 'null'] }
        assigned_to:      { type: [string, 'null'] }
        work_category_id: { type: [string, 'null'] }
        km:
          type: [number, 'null']
          description: |
            Odometer reading captured when the worksheet was opened.
            Free-form decimal because some workshops record fuel
            mileage or hour-meter values in the same field.
        created_by: { type: string }
        created_by_full_name:
          type: string
          description: |
            Display string of the user who created the worksheet —
            denormalized for list rendering without a join.
        customer_name:      { type: string }
        vehicle_name:       { type: [string, 'null'] }
        vehicle_license:    { type: [string, 'null'] }
        work_category_name: { type: [string, 'null'] }
        total_before_tax:     { type: number }
        total_tax:            { type: number }
        total_after_tax:      { type: number }
        total_after_discount: { type: number }
        total_discount:       { type: number }
        total_price:          { type: number }
        total_payments:       { type: number }
        outstanding_payments: { type: number }
        fiscal_document_type:
          type: string
          description: |
            Which fiscal document the worksheet produces: "fattura"
            (default) or "scontrino".
        invoice_id:
          type: [string, 'null']
          description: |
            Detail responses only — the first linked invoice, when one
            exists. Drives the Vedi Fattura action.
        receipt_id:
          type: [string, 'null']
          description: |
            Detail responses only — the linked scontrino for
            scontrino-type worksheets, when one exists.
        linked_invoices:
          type: array
          description: |
            Detail responses only — compact rows for the Fattura tab
            (fattura-type worksheets).
          items: { $ref: '#/components/schemas/WorksheetFiscalDocument' }
        linked_receipt:
          description: |
            Detail responses only — the compact row for the Scontrino
            tab (scontrino-type worksheets), when one exists.
          allOf:
            - $ref: '#/components/schemas/WorksheetFiscalDocument'
        frozen:
          type: boolean
          description: |
            Detail responses only (since 2026-07-19) — true when a linked
            invoice has a non-draft electronic invoice; mutations are
            rejected with 409 and the app hides/blocks Modifica.
        total_work_cost:
          type: number
          description: Internal labour cost (Riepilogo Costi).
        total_article_cost:
          type: number
          description: Internal parts cost (Riepilogo Costi).
        waiting_at:   { type: [string, 'null'], format: date-time }
        started_at:   { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        archived_at:  { type: [string, 'null'], format: date-time }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated on the response from
            DELETE /v1/worksheets/{id}. Worksheets with a non-null
            deleted_at are not returned by list/detail endpoints.
        line_items:
          type: [array, 'null']
          description: |
            Embedded line items. Populated on detail / create / patch
            / delete / transition responses. List responses set this
            to `null` to keep the payload small.
          items: { $ref: '#/components/schemas/WorkLineItem' }
        documents:
          type: [array, 'null']
          description: |
            Attachments (Documenti Allegati). Populated on the same
            responses as `line_items`; null on lists.
          items: { $ref: '#/components/schemas/WorksheetDocument' }

    WorksheetStatusCounts:
      type: object
      required: [object, draft, waiting, started, completed, delivered, archived, date_filter]
      properties:
        object:
          type: string
          const: worksheet_status_counts
        draft:     { type: integer }
        waiting:   { type: integer }
        started:   { type: integer }
        completed: { type: integer }
        delivered: { type: integer }
        archived:  { type: integer }
        date_filter: { $ref: '#/components/schemas/DateFilter' }

    QuoteStatusCounts:
      type: object
      required: [object, waiting, accepted, rejected, date_filter]
      properties:
        object:
          type: string
          const: quote_status_counts
        waiting:  { type: integer }
        accepted: { type: integer }
        rejected: { type: integer }
        date_filter: { $ref: '#/components/schemas/DateFilter' }

    InvoicePaymentCounts:
      type: object
      required: [object, pending, settled, date_filter]
      description: >-
        Tab counters for the invoices list. pending = outstanding
        payments > 0, settled = fully paid.
      properties:
        object:
          type: string
          const: invoice_payment_counts
        pending: { type: integer }
        settled: { type: integer }
        date_filter: { $ref: '#/components/schemas/DateFilter' }

    CustomerDocument:
      type: object
      required: [object, id, name, content_type, url, file_size_bytes, created_at]
      description: |
        One customer attachment. `url` is the document's authenticated
        download route
        (`/v1/customers/{id}/documents/{documentID}/download`) — fetch it
        with the bearer to stream the bytes.
      properties:
        object:
          type: string
          const: customer_document
        id:              { type: string }
        name:            { type: string }
        content_type:    { type: string }
        description:     { type: [string, 'null'] }
        url:             { type: string, example: '/v1/customers/0b6b…/documents/77c2…/download' }
        file_size_bytes: { type: integer }
        created_at:      { type: string, format: date-time }

    WorksheetDocument:
      type: object
      required: [object, id, name, content_type, url, file_size_bytes, created_at]
      description: One worksheet attachment; `url` opens the stored file.
      properties:
        object:
          type: string
          const: worksheet_document
        id:              { type: string }
        name:            { type: string }
        content_type:    { type: string }
        description:     { type: [string, 'null'] }
        url:             { type: string }
        file_size_bytes: { type: integer }
        created_at:      { type: string, format: date-time }

    Payment:
      type: object
      required: [object, id, amount, payment_method, payment_date, created_at]
      description: |
        One registered payment on a worksheet's invoice. payment_method
        is an SDI ModalitaPagamento code (MP01–MP23).
      properties:
        object:
          type: string
          const: payment
        id:             { type: string }
        amount:         { type: number, format: double }
        payment_method: { type: string }
        payment_date:   { type: string, format: date-time }
        created_at:     { type: string, format: date-time }

    PaymentCreateRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: number
          format: double
          description: Must be greater than zero.
        payment_method:
          type: string
          description: |
            MP01–MP23. Omitted/invalid values fall back to the
            customer's preferred method, then the company default.
        payment_date:
          type: string
          format: date
          description: YYYY-MM-DD; default today.
        financial_account_id:
          type: string
          description: |
            Conto di saldo (Banca, Cassa, …) — one of the company's
            accounts from GET /v1/financial-accounts. Optional; an
            account belonging to another company is rejected with 422.
      additionalProperties: false

    WorkLineItem:
      type: object
      required:
        - object
        - id
        - description
        - quantity
        - unit_type
        - unit_price
        - tax_rate
        - total_price
        - position
        - created_at
      description: |
        A single line on a worksheet. Returned read-only on every
        worksheet response; mutate via
        `POST /v1/worksheets/{id}/line-items/batch`.
      properties:
        object:
          type: string
          const: work_line_item
        id: { type: string }
        article_id:
          type: [string, 'null']
          description: |
            Optional link to a catalog article. When set, edits
            flow through the article inventory bookkeeping.
        description:        { type: string }
        quantity:           { type: number }
        unit_type:          { type: string }
        unit_price:         { type: number }
        unit_discount_rate: { type: number }
        tax_rate:           { type: number }
        vat_code:           { type: [string, 'null'] }
        total_price:        { type: number }
        total_before_tax:   { type: number }
        total_tax:          { type: number }
        total_after_tax:    { type: number }
        position:           { type: integer }
        created_at:         { type: string, format: date-time }

    # ─── Resource: Quote ───

    Quote:
      type: object
      required:
        - object
        - id
        - seq_number
        - status
        - customer_id
        - customer_name
        - created_at
        - updated_at
      description: |
        A customer-facing job estimate. Lifecycle: `waiting` →
        `accepted` / `rejected`. Totals are server-computed from the
        embedded line items and are read-only on the wire.
      properties:
        object:
          type: string
          const: quote
        id:         { type: string }
        seq_number: { type: integer }
        status:
          type: string
          enum: [waiting, accepted, rejected]
        title:                { type: [string, 'null'] }
        comments:             { type: [string, 'null'] }
        internal_comments:    { type: [string, 'null'] }
        customer_id:          { type: string }
        customer_name:        { type: string }
        vehicle_id:           { type: [string, 'null'] }
        vehicle_name:         { type: [string, 'null'] }
        vehicle_license:      { type: [string, 'null'] }
        work_category_id:     { type: [string, 'null'] }
        work_category_name:   { type: [string, 'null'] }
        km:                   { type: [number, 'null'], format: double }
        worksheet_id:         { type: [string, 'null'] }
        worksheet_seq_number: { type: [integer, 'null'] }
        worksheet_created_at: { type: [string, 'null'], format: date-time }
        total_before_tax:          { type: number, format: double }
        total_tax:                 { type: number, format: double }
        total_after_tax:           { type: number, format: double }
        total_discount:            { type: number, format: double }
        total_discount_before_tax: { type: number, format: double }
        total_after_discount:      { type: number, format: double }
        total_work:                { type: number, format: double }
        total_articles:            { type: number, format: double }
        hide_discounts:            { type: boolean }
        line_items:
          type: [array, 'null']
          description: |
            Embedded line items. Populated on detail / write
            responses; list responses set this to `null` to keep
            payloads small.
          items: { $ref: '#/components/schemas/QuoteLineItem' }
        delivered_at: { type: [string, 'null'], format: date-time }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }
        archived_at:  { type: [string, 'null'], format: date-time }
        deleted_at:   { type: [string, 'null'], format: date-time }

    QuoteLineItem:
      type: object
      required:
        - object
        - id
        - description
        - unit_type
        - quantity
        - unit_price
        - position
        - created_at
      properties:
        object:
          type: string
          const: quote_line_item
        id:                 { type: string }
        article_id:         { type: [string, 'null'] }
        description:        { type: string }
        unit_type:          { type: string }
        quantity:           { type: number, format: double }
        unit_price:         { type: number, format: double }
        unit_discount_rate: { type: number, format: double }
        tax_rate:           { type: number, format: double }
        vat_code:           { type: [string, 'null'] }
        total_price:        { type: number, format: double }
        total_before_tax:   { type: number, format: double }
        total_tax:          { type: number, format: double }
        total_after_tax:    { type: number, format: double }
        position:           { type: integer }
        created_at:         { type: string, format: date-time }

    # ─── Resource: Invoice ───

    Invoice:
      type: object
      required:
        - object
        - id
        - status
        - customer_id
        - seq_number
        - payment_method
        - payment_condition
        - payment_due_at
        - vat_exigibility
        - total_before_tax
        - total_tax
        - total_after_tax
        - total_discount
        - total_after_discount
        - total_payments
        - outstanding_payments
        - customer_name
        - created_by_full_name
        - sdi_status
        - created_at
        - updated_at
      description: |
        A workshop invoice. Multi-tenant: belongs to one company.
        Created from a worksheet; transitions draft → issued via
        `POST /v1/invoices/{id}/issue`.
      properties:
        object:
          type: string
          const: invoice
        id:
          type: string
          example: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04
        status:
          type: string
          enum: [draft, issued]
          description: |
            Derived from `issued_at`. Soft-deleted invoices 404
            before reaching the client — "deleted" is never emitted.
        title:              { type: [string, 'null'] }
        comments:           { type: [string, 'null'] }
        internal_comments:  { type: [string, 'null'] }
        payment_method:     { type: string }
        payment_condition:  { type: string }
        payment_due_at:     { type: string, format: date-time }
        vat_exigibility:    { type: string }
        sezionale_id:       { type: [string, 'null'] }
        pa_document_type:   { type: [string, 'null'] }
        pa_document_number: { type: [string, 'null'] }
        pa_document_date:   { type: [string, 'null'], format: date-time }
        pa_cig:             { type: [string, 'null'] }
        pa_cup:             { type: [string, 'null'] }
        customer_id:        { type: string }
        vehicle_id:         { type: [string, 'null'] }
        worksheet_id:       { type: [string, 'null'] }
        seq_number:
          type: integer
          description: |
            Per-company monotonic invoice number. 0 on drafts;
            assigned atomically at issuance.
        total_before_tax:
          type: number
          format: double
          description: |
            Money. Float64 — clients render via the workshop locale
            formatter; avoid arithmetic.
        total_tax:            { type: number, format: double }
        total_after_tax:      { type: number, format: double }
        total_discount:       { type: number, format: double }
        total_after_discount: { type: number, format: double }
        total_payments:       { type: number, format: double }
        outstanding_payments: { type: number, format: double }
        customer_name:        { type: string }
        vehicle_name:         { type: [string, 'null'] }
        vehicle_license:      { type: [string, 'null'] }
        created_by_full_name: { type: string }
        sezionale_code:       { type: [string, 'null'] }
        work_category_name:   { type: [string, 'null'] }
        sdi_status:
          type: string
          description: |
            Italian SDI e-invoice lifecycle status. Empty string
            when no e-invoice exists for this invoice. Possible
            non-empty values: "sent", "rejected", "pending_normal",
            "pending_warning", "pending_late".
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        issued_at:
          type: [string, 'null']
          format: date-time
          description: Populated once the invoice is issued.
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated only on the response
            from DELETE /v1/invoices/{id}.
        line_items:
          type: array
          description: |
            Populated on detail and post-mutation responses. Omitted
            from list rows to keep payloads small.
          items: { $ref: '#/components/schemas/InvoiceLineItem' }

    InvoiceLineItem:
      type: object
      required:
        - object
        - id
        - description
        - quantity
        - unit_type
        - unit_price
        - unit_discount_rate
        - tax_rate
        - total_price
        - total_before_tax
        - total_tax
        - total_after_tax
        - created_at
      description: |
        A single row on an invoice. Read-only via this API — line
        item add/remove is a separate concern not yet exposed on
        the mobile surface.
      properties:
        object:
          type: string
          const: invoice_line_item
        id:                 { type: string }
        article_id:         { type: [string, 'null'] }
        description:        { type: string }
        quantity:           { type: number, format: double }
        unit_type:          { type: string }
        unit_price:         { type: number, format: double }
        unit_cost:          { type: [number, 'null'], format: double }
        unit_discount_rate: { type: number, format: double }
        tax_rate:           { type: number, format: double }
        vat_code:           { type: [string, 'null'] }
        vat_description:    { type: [string, 'null'] }
        total_price:        { type: number, format: double }
        total_before_tax:   { type: number, format: double }
        total_tax:          { type: number, format: double }
        total_after_tax:    { type: number, format: double }
        created_at:         { type: string, format: date-time }

    # ─── Resource: Credit note ───

    CreditNote:
      type: object
      required:
        - object
        - id
        - invoice_id
        - customer_name
        - vehicle_name
        - vehicle_license
        - sdi_status
        - latest_sdi_status
        - total_before_tax
        - total_tax
        - total_after_tax
        - created_at
        - updated_at
      description: |
        A credit note (nota di credito) — a derivative document
        created from a source invoice. Multi-tenant: belongs to one
        company. Totals mirror the source invoice and are not
        client-editable. The only mutable field is `title`.
      properties:
        object:
          type: string
          const: credit_note
        id:
          type: string
          example: cnt_4412
        invoice_id:
          type: string
          description: Source invoice UUID. Immutable.
        seq_number:
          type: [integer, 'null']
          description: |
            Per-company / per-sezionale monotonic number. May be
            null on freshly created credit notes that have not yet
            been issued — clients render "Bozza" / draft when
            absent.
        title:         { type: [string, 'null'] }
        sezionale_id:  { type: [string, 'null'] }
        sezionale_code:
          type: [string, 'null']
          description: |
            Denormalized sezionale code for display. Read-only;
            mutate via `sezionale_id` at creation time.
        customer_name:
          type: string
          description: |
            Denormalized display string of the source invoice's
            customer.
        vehicle_name:
          type: string
          description: Denormalized display string of the linked vehicle.
        vehicle_license: { type: string }
        sdi_status:
          type: string
          description: |
            Italian SDI e-invoice lifecycle status. Empty string
            when no e-credit-note exists.
        latest_sdi_status:
          type: string
          description: |
            Most recent terminal SDI status observed for this
            credit note's electronic counterpart.
        total_before_tax: { type: number, format: double }
        total_tax:        { type: number, format: double }
        total_after_tax:  { type: number, format: double }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated on the response from
            DELETE /v1/credit-notes/{id}. Credit notes with a
            non-null deleted_at are not returned by list/detail
            endpoints — this field appears for the delete response
            only.

    # ─── Resource: Checkin ───

    Checkin:
      type: object
      required:
        - object
        - id
        - seq_number
        - status
        - customer_id
        - vehicle_id
        - customer_name
        - vehicle_name
        - created_by
        - created_at
        - updated_at
      description: |
        A vehicle intake / inspection snapshot. Belongs to one
        company, one customer, and one vehicle. List responses omit
        `line_items` to keep payloads small; detail responses
        populate it.
      properties:
        object:
          type: string
          const: checkin
        id:
          type: string
          example: chk_4412
        seq_number:
          type: integer
          description: |
            Per-company human-readable sequence (e.g. "Checkin #142").
            Assigned at create time and stable afterwards.
        status:
          type: string
          enum: [active, completed]
          description: |
            Lifecycle state. Mutated only through
            `POST /v1/checkins/{id}/transitions`, never via PATCH.
        title:             { type: [string, 'null'] }
        comments:          { type: [string, 'null'] }
        internal_comments:
          type: [string, 'null']
          description: Notes visible only to workshop staff.
        customer_id:       { type: string }
        vehicle_id:        { type: string }
        work_category_id:  { type: [string, 'null'] }
        worksheet_id:
          type: [string, 'null']
          description: Set when the checkin has been converted into a worksheet.
        quote_id:
          type: [string, 'null']
          description: Set when the checkin has been converted into a quote.
        fuel_level:
          type: [integer, 'null']
          description: |
            Free-form integer captured at intake. Convention is
            0–100 (percentage) but the API enforces no bounds today.
        section1: { type: [string, 'null'] }
        section2: { type: [string, 'null'] }
        section3: { type: [string, 'null'] }
        section4: { type: [string, 'null'] }
        section5: { type: [string, 'null'] }
        section6: { type: [string, 'null'] }
        section7: { type: [string, 'null'] }
        section8: { type: [string, 'null'] }
        km:
          type: [number, 'null']
          description: Odometer reading at intake.
        created_by:
          type: string
          description: User id of the staff member who created the checkin.
        customer_name:
          type: string
          description: |
            Denormalized display string of the linked customer.
            Pre-computed on the entity for list-row rendering
            without a second request.
        vehicle_name:
          type: string
          description: Denormalized display string of the linked vehicle.
        vehicle_license:    { type: [string, 'null'] }
        work_category_name: { type: [string, 'null'] }
        delivered_at:       { type: [string, 'null'], format: date-time }
        created_at:         { type: string, format: date-time }
        updated_at:         { type: string, format: date-time }
        deleted_at:
          type: [string, 'null']
          format: date-time
          description: |
            Soft-deletion timestamp. Populated on the response from
            DELETE /v1/checkins/{id}. Checkins with a non-null
            deleted_at are not returned by list/detail endpoints —
            this field appears for the delete response only.
        line_items:
          type: [array, 'null']
          description: |
            Embedded line items (description-only notes). Populated on
            detail / batch responses; list GET emits `null` to keep
            payloads small. Mutate via
            POST /v1/checkins/{id}/line-items/batch.
          items: { $ref: '#/components/schemas/CheckinLineItem' }

    CheckinLineItem:
      type: object
      required: [object, id, description, created_at]
      description: |
        A description-only line item (note) on a checkin. Returned
        read-only on checkin responses; mutate via
        POST /v1/checkins/{id}/line-items/batch.
      properties:
        object:
          type: string
          const: checkin_line_item
        id:          { type: string }
        description: { type: string }
        created_at:  { type: string, format: date-time }

    # ─── Resource: Calendar entry ───

    CalendarEntry:
      type: object
      required:
        - object
        - id
        - title
        - user_id
        - start_time
        - end_time
        - color
        - email_reminder
        - auto_reminder_enabled
        - created_at
        - updated_at
      description: |
        A workshop appointment. Belongs to one company (the
        caller's) and is owned by the user who created it. May
        reference an optional Customer, Vehicle, work category, and
        parent Calendar.
      properties:
        object:
          type: string
          const: calendar_entry
        id:                  { type: string, format: uuid }
        title:               { type: string }
        description:         { type: [string, 'null'] }
        user_id:             { type: string, format: uuid }
        start_time:          { type: string, format: date-time }
        end_time:            { type: string, format: date-time }
        color:
          type: string
          description: 'Hex colour, e.g. #2563eb'
        email_reminder:      { type: boolean }
        customer_id:         { type: [string, 'null'], format: uuid }
        vehicle_id:          { type: [string, 'null'], format: uuid }
        worksheet_id:        { type: [string, 'null'], format: uuid }
        quote_id:            { type: [string, 'null'], format: uuid }
        checkin_id:          { type: [string, 'null'], format: uuid }
        work_category_id:    { type: [string, 'null'], format: uuid }
        work_category_name:  { type: [string, 'null'] }
        calendar_id:         { type: [string, 'null'], format: uuid }
        auto_reminder_enabled: { type: boolean }
        auto_reminder_channel:
          type: [string, 'null']
          enum: [email, sms, whatsapp, null]
        auto_reminder_sent_at: { type: [string, 'null'], format: date-time }
        customer:
          oneOf:
            - $ref: '#/components/schemas/CalendarEntryCustomer'
            - type: 'null'
        vehicle:
          oneOf:
            - $ref: '#/components/schemas/CalendarEntryVehicle'
            - type: 'null'
        user:
          oneOf:
            - $ref: '#/components/schemas/CalendarEntryUser'
            - type: 'null'
        calendar:
          oneOf:
            - $ref: '#/components/schemas/CalendarEntryCalendar'
            - type: 'null'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }

    CalendarEntryCustomer:
      type: object
      required: [object, id]
      description: |
        Slim denormalized view of the linked customer, embedded
        inside a CalendarEntry. The full Customer resource lives at
        `/v1/customers/{id}`.
      properties:
        object:
          type: string
          const: customer
        id:        { type: string, format: uuid }
        full_name: { type: [string, 'null'] }
        email:     { type: [string, 'null'] }
        phone:     { type: [string, 'null'] }

    CalendarEntryVehicle:
      type: object
      required: [object, id, model, manufacturer_name]
      description: |
        Slim denormalized view of the linked vehicle, embedded
        inside a CalendarEntry. The full Vehicle resource lives at
        `/v1/vehicles/{id}`.
      properties:
        object:
          type: string
          const: vehicle
        id:                { type: string, format: uuid }
        license:           { type: [string, 'null'] }
        model:             { type: string }
        manufacturer_name: { type: string }

    CalendarEntryUser:
      type: object
      required: [object, id, name]
      description: |
        Slim denormalized view of the owning user, embedded inside a
        CalendarEntry.
      properties:
        object:
          type: string
          const: user
        id:   { type: string, format: uuid }
        name: { type: string }

    CalendarEntryCalendar:
      type: object
      required: [object, id, name]
      description: |
        Slim denormalized view of the parent calendar, embedded
        inside a CalendarEntry. The Calendar container itself is
        not exposed on this tier.
      properties:
        object:
          type: string
          const: calendar
        id:   { type: string, format: uuid }
        name: { type: string }

    # ─── Resource: Cashbook ───

    CashBookEntry:
      type: object
      required:
        - object
        - id
        - payment_type
        - amount
        - payment_date
        - financial_account_name
        - running_balance
      description: |
        One row of the cashbook: either an income (linked to an
        invoice or worksheet) or an expense (linked to a supplier
        bill). Computed from underlying payments — no first-class
        lifecycle.
      properties:
        object:
          type: string
          const: cashbook_entry
        id:
          type: string
          description: Underlying payment UUID. Opaque to clients.
        payment_type:
          type: string
          enum: [income, expense]
          description: |
            Side of the ledger. Clients branch on this to decide
            which side-specific fields (invoice_/worksheet_ vs
            expense_, customer_name vs supplier_name) are populated.
        amount:
          type: number
          format: double
          description: |
            Signed payment amount in the workshop's currency.
            Float64 matches the entity layer; the cashbook computes
            a running balance, so integer cents would force clients
            to redo the math.
        payment_date:
          type: string
          format: date-time
          description: When the payment was recorded. RFC 3339 UTC.
        financial_account_id:
          type: [string, 'null']
          description: |
            Account this payment hit. Nullable — legacy entries may
            predate the financial-accounts feature.
        financial_account_name:
          type: string
          description: |
            Denormalized account name. Empty string for legacy
            entries with no account assignment.
        customer_name:
          type: string
          description: |
            Income-side counterparty. Omitted from the JSON entirely
            on expense rows (no empty key).
        supplier_name:
          type: string
          description: |
            Expense-side counterparty. Omitted from the JSON
            entirely on income rows.
        invoice_id:
          type: [string, 'null']
          description: Source invoice UUID (income rows).
        invoice_seq_number:
          type: [integer, 'null']
          description: |
            Sequence number of the linked issued invoice, if any.
            Set on income rows backed by an issued invoice.
        invoice_issued_at:  { type: [string, 'null'], format: date-time }
        worksheet_id:
          type: [string, 'null']
          description: |
            Source worksheet UUID, set on income rows backed by a
            worksheet whose invoice is still a draft.
        worksheet_seq_number:
          type: [integer, 'null']
          description: |
            Sequence number of the linked worksheet, set on income
            rows backed by a worksheet whose invoice is still a draft.
        worksheet_created_at: { type: [string, 'null'], format: date-time }
        receipt_id:
          type: [string, 'null']
          description: Source receipt (scontrino) UUID (income rows).
        receipt_seq_number:
          type: [integer, 'null']
          description: Sequence number of the linked standalone scontrino.
        receipt_issued_at: { type: [string, 'null'], format: date-time }
        expense_id:
          type: [string, 'null']
          description: Source expense UUID (expense rows).
        expense_seq_number:
          type: [integer, 'null']
          description: Sequence number of the linked supplier expense.
        expense_created_at: { type: [string, 'null'], format: date-time }
        running_balance:
          type: number
          format: double
          description: |
            Cumulative balance after applying this row, starting from
            `opening_balance` on the list envelope. Computed by the
            server in `order_direction` — do not re-sort client-side
            without recomputing.

    # ─── Collection envelopes ───
    #
    # Every list endpoint returns this shape with `data` typed via
    # allOf composition. The envelope itself is tier-agnostic and
    # reused unchanged by the future public API.

    DateFilter:
      type: object
      required: [object, preset]
      description: |
        The effective date range the server applied to a date-scoped
        list request, echoed on the list envelope so clients can render
        the active filter without re-deriving it.

        The filter is sticky per user/per view: an explicit
        `from`/`to`/`preset` is persisted and reused on later unfiltered
        requests; with nothing stored the default is the last 12 months
        (`rel365`). Relative presets are resolved here to concrete dates
        and roll forward over time.
      properties:
        object:
          type: string
          const: date_filter
        preset:
          type: string
          description: |
            Effective selection label. Relative (`rel30`/`rel90`/`rel365`)
            and absolute (`year`/`Q1`-`Q4`/`month`/`custom`) carry
            `from`/`to`; `all` omits them.
          enum: [rel30, rel90, rel365, year, Q1, Q2, Q3, Q4, month, custom, all]
        from:
          type: string
          format: date
          description: Inclusive lower bound (YYYY-MM-DD). Omitted when `preset` is `all`.
        to:
          type: string
          format: date
          description: Inclusive upper bound (YYYY-MM-DD). Omitted when `preset` is `all`.

    CalendarFilter:
      type: object
      required: [object, calendar_id]
      description: |
        The effective calendar selection the server applied to a calendar-entry
        timeframe listing, echoed on the list envelope so clients can render the
        active calendar without re-deriving it.

        Sticky per user: an explicit `calendar_id` is persisted and reused on
        later unfiltered requests. An empty `calendar_id` means "all calendars".
        Echoed only on the default timeframe listing — `customer_id` /
        `vehicle_id` lookups don't apply a calendar filter.
      properties:
        object:
          type: string
          const: calendar_filter
        calendar_id:
          type: string
          description: Selected parent calendar id, or empty for all calendars.

    ListEnvelope:
      type: object
      required: [object, data, has_more, url]
      properties:
        object:
          type: string
          const: list
          description: Polymorphic discriminator.
        data:
          type: array
          description: Page of items. Each item carries its own `object` field.
          items: {}
        has_more:
          type: boolean
          description: |
            True if more results exist beyond this page. Use this
            for the "load more" UI — `data.length` alone is
            unreliable because a full page may still be the last
            page.
        next_starting_after:
          type: string
          description: |
            Opaque cursor for the next page. Present (and non-empty)
            when `has_more` is `true`; omitted otherwise. Pass the
            value verbatim as `?starting_after=` on the follow-up
            request. Clients MUST NOT parse it — today it encodes
            an offset, tomorrow it may encode a `(created_at, id)`
            tuple without a wire-visible change.
        url:
          type: string
          description: The request path that produced this list.

    LicensePlateLookup:
      type: object
      required: [object, license_plate, manufacturer_id, manufacturer_name, model, variant, vin, engine_category, engine_power_kw, engine_displacement_ccm, registered_at, comments, credits_spent, credit_balance]
      description: |
        Visura targa result, pre-mapped onto the vehicle create-form
        fields. A successful lookup has already deducted
        `credits_spent` from the balance.
      properties:
        object:                  { type: string, const: license_plate_lookup }
        license_plate:           { type: string }
        manufacturer_id:
          type: [string, 'null']
          description: Fuzzy-matched company manufacturer; null when none matched.
        manufacturer_name:       { type: string, description: 'The raw make from the database.' }
        model:                   { type: string }
        variant:                 { type: string }
        vin:                     { type: string }
        engine_category:
          type: string
          description: Internal fuel enum (diesel, gasoline, …); empty when unknown.
        engine_power_kw:         { type: [integer, 'null'] }
        engine_displacement_ccm: { type: [integer, 'null'] }
        registered_at:           { type: [string, 'null'], format: date-time }
        comments:                { type: string, description: 'The "Dati da banca dati" notes block, newline-separated.' }
        credits_spent:           { type: number }
        credit_balance:          { type: number }

    WorksheetFiscalDocument:
      type: object
      required: [object, id, seq_number, issued_at, created_at, total_after_discount, outstanding_payments]
      description: |
        One compact linked fiscal document on a worksheet detail —
        seq_number 0 means a draft ("Non emessa" / "Bozza").
      properties:
        object:               { type: string, const: worksheet_fiscal_document }
        id:                   { type: string }
        seq_number:           { type: integer }
        issued_at:            { type: [string, 'null'], format: date-time }
        created_at:           { type: string, format: date-time }
        total_after_discount: { type: number, format: double }
        outstanding_payments: { type: number, format: double }

    CashBookListEnvelope:
      description: |
        Extends `ListEnvelope` with the two fields the cashbook
        needs to be self-consistent: `opening_balance` (the
        running-balance anchor) and `total_items` (the full
        filtered count). The envelope's `{object, data, has_more,
        url}` shape stays wire-compatible with the generic list
        parser; the extra fields are inert to clients that don't
        know about them.
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          required: [opening_balance, total_items]
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/CashBookEntry' }
            opening_balance:
              type: number
              format: double
              description: |
                Running-balance anchor at the start of the `from`
                window. Zero when `from` is unset.
            total_items:
              type: integer
              description: Full filtered count, ignoring pagination.
            date_filter:
              $ref: '#/components/schemas/DateFilter'

    # ─── Error envelope (4xx/5xx body shape) ───

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'

    ErrorBody:
      type: object
      required: [type, status, code, message]
      description: |
        Single error per response (fast-fail). Inspired by RFC 9457
        Problem Details, wrapped in an `error` root key so success
        and error bodies are visually distinguishable.
      properties:
        type:
          type: string
          format: uri
          description: URI identifying the error class. Stable.
          example: https://api.officina.it/errors/invalid_credentials
        status:
          type: integer
          description: HTTP status code, mirrored from the response.
          example: 401
        code:
          type: string
          description: |
            Stable machine-readable identifier (snake_case). Clients
            branch on this, never on `message`.
          example: invalid_credentials
        message:
          type: string
          description: Human-readable English explanation.
        param:
          type: string
          description: |
            JSON Pointer (RFC 6901) to the offending request field.
            Omitted when the error isn't tied to a specific input
            field (e.g. auth errors).
          example: /email
        trace_id:
          type: string
          description: Request trace ID for support correlation.

  # ───────────────────────────────────────────────────────────────
  # Reusable response declarations
  # ───────────────────────────────────────────────────────────────
  #
  # Every error response across every endpoint is one of these.
  # When adding a new code, add a response here and reference it
  # from each affected endpoint with `$ref`.
    ArticleCreateRequest:
      type: object
      description: |
        ArticleCreateRequest / ArticlePatchRequest — the magazzino and
        listino manodopera editors. unit_type decides the kind.
      required:
        - name
        - code
        - unit_type
        - stock
        - minimum_stock
        - purchase_price
        - sale_price
        - vat_code
        - unit_discount_rate
        - location
        - comments
        - supplier_id
      properties:
        name:
          type: string
        code:
          type: [string, null]
        unit_type:
          type: string
        stock:
          type: [number, null]
        minimum_stock:
          type: [number, null]
        purchase_price:
          type: [number, null]
        sale_price:
          type: [number, null]
        vat_code:
          type: string
        unit_discount_rate:
          type: [integer, null]
        location:
          type: [string, null]
        comments:
          type: [string, null]
        supplier_id:
          type: [string, null]
    ArticleMovement:
      type: object
      description: |
        ArticleMovement is one "Storico lavorazioni" row for an article /
        lavorazione — a sale movement where the item was used. Exactly one
        source (worksheet / order / receipt) is set; SourceID is the id
        the mobile client navigates to.
      required:
        - object
        - id
        - quantity
        - source
        - source_id
        - seq_number
        - document_date
        - customer_name
        - vehicle_name
        - total
        - created_at
      properties:
        object:
          type: string
          description: "always \"article_movement\""
        id:
          type: string
        quantity:
          type: number
        source:
          type: string
          description: "\"worksheet\" | \"order\" | \"receipt\" | \"\""
        source_id:
          type: [string, null]
        seq_number:
          type: [integer, null]
        document_date:
          type: [string, null]
          format: date-time
        customer_name:
          type: [string, null]
        vehicle_name:
          type: [string, null]
        total:
          type: [number, null]
        created_at:
          type: string
          format: date-time
    CalendarListEnvelope:
      type: object
      description: |
        CalendarListEnvelope: success body for GET /v1/calendars. Follows
        the standard list envelope; the collection is unpaginated
        (workshops have a handful of calendars at most) so HasMore is
        always false.
      required:
        - object
        - data
        - has_more
        - url
      properties:
        object:
          type: string
          description: "always \"list\""
        data:
          type: array
          items:
            $ref: '#/components/schemas/Calendar'
        has_more:
          type: boolean
        url:
          type: string
    CheckinDocument:
      type: object
      description: |
        CheckinDocument: one attachment on a checkin (Documenti Allegati).
        Populated on detail responses only. URL points at the
        authenticated download route.
      required:
        - object
        - id
        - name
        - content_type
        - description
        - url
        - file_size_bytes
        - created_at
      properties:
        object:
          type: string
          description: "always \"checkin_document\""
        id:
          type: string
        name:
          type: string
        content_type:
          type: string
        description:
          type: [string, null]
        url:
          type: string
        file_size_bytes:
          type: integer
        created_at:
          type: string
          format: date-time
    CheckinStatusCounts:
      type: object
      description: |
        CheckinStatusCounts: tab counters for the checkins list (web
        checkin-list StatusCount parity), computed with the same q + date
        filters as the list.
      required:
        - object
        - active
        - completed
        - date_filter
      properties:
        object:
          type: string
          description: "always \"checkin_status_counts\""
        active:
          type: integer
        completed:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    CompanyPatchRequest:
      type: object
      required:
        - name
        - entity_type
        - company_type
        - tax_id
        - fiscal_code
        - email
        - phone
        - address
        - city
        - zip_code
        - province
        - checkin_frame
        - primary_color
        - document_template_name
        - default_payment_method
        - default_payment_due_at
        - bank_account_iban
        - bank_account_holder
        - quote_hide_line_item_details
        - worksheet_hide_line_item_details
        - worksheet_line_item_code_enabled
        - line_items_including_vat
        - worksheet_km_required
      properties:
        name:
          description: Free-form value.
        entity_type:
          description: Free-form value.
          description: "company | individual"
        company_type:
          description: Free-form value.
          description: car_repair, tyre_repair, …
        tax_id:
          description: Free-form value.
        fiscal_code:
          description: Free-form value.
        email:
          description: Free-form value.
        phone:
          description: Free-form value.
        address:
          description: Free-form value.
        city:
          description: Free-form value.
        zip_code:
          description: Free-form value.
        province:
          description: Free-form value.
          description: "Personalizza accettazioni: \"auto\" | \"moto\"."
        checkin_frame:
          description: Free-form value.
          description: Personalizza documenti.
        primary_color:
          description: Free-form value.
        document_template_name:
          description: Free-form value.
        default_payment_method:
          description: Free-form value.
        default_payment_due_at:
          description: Free-form value.
        bank_account_iban:
          description: Free-form value.
        bank_account_holder:
          description: Free-form value.
        quote_hide_line_item_details:
          description: Free-form value.
        worksheet_hide_line_item_details:
          description: Free-form value.
        worksheet_line_item_code_enabled:
          description: Free-form value.
        line_items_including_vat:
          description: Free-form value.
        worksheet_km_required:
          description: Free-form value.
    CompanyProfile:
      type: object
      description: |
        CompanyProfile — Informazioni officina (anagrafica scope).
      required:
        - object
        - id
        - name
        - entity_type
        - company_type
        - tax_id
        - fiscal_code
        - email
        - phone
        - phone_national
        - address
        - city
        - zip_code
        - province
        - fiscal_fields_locked
        - checkin_frame
        - primary_color
        - document_template_name
        - default_payment_method
        - default_payment_due_at
        - bank_account_iban
        - bank_account_holder
        - quote_hide_line_item_details
        - worksheet_hide_line_item_details
        - worksheet_line_item_code_enabled
        - line_items_including_vat
        - worksheet_km_required
      properties:
        object:
          type: string
          description: "always \"company\""
        id:
          type: string
        name:
          type: string
        entity_type:
          type: string
          description: "company | individual"
        company_type:
          type: [string, null]
        tax_id:
          type: [string, null]
        fiscal_code:
          type: [string, null]
        email:
          type: [string, null]
        phone:
          type: [string, null]
          description: "PhoneNational is Phone without the +39 country code, for an inset-\"+39\""
        phone_national:
          type: [string, null]
        address:
          type: [string, null]
        city:
          type: [string, null]
        zip_code:
          type: [string, null]
        province:
          type: [string, null]
          description: True when an ACube business registry configuration exists
        fiscal_fields_locked:
          type: boolean
          description: "\"auto\" | \"moto\" — the sagoma used on the checkin damage diagram."
        checkin_frame:
          type: string
          description: Personalizza documenti (aspetto/pagamenti/opzioni). Logo stays
        primary_color:
          type: [string, null]
        document_template_name:
          type: string
        default_payment_method:
          type: string
        default_payment_due_at:
          type: string
        bank_account_iban:
          type: [string, null]
        bank_account_holder:
          type: [string, null]
        quote_hide_line_item_details:
          type: boolean
        worksheet_hide_line_item_details:
          type: boolean
        worksheet_line_item_code_enabled:
          type: boolean
        line_items_including_vat:
          type: boolean
        worksheet_km_required:
          type: boolean
    CustomerMessageEntity:
      type: object
      description: |
        CustomerMessageEntity — one eligible target of the Invia messaggio
        flow (web customer-{channel}-entity-list parity).
      required:
        - object
        - id
        - seq_number
        - title
        - vehicle_name
        - vehicle_license
        - created_at
        - total
      properties:
        object:
          type: string
          description: "always \"customer_message_entity\""
        id:
          type: string
        seq_number:
          type: integer
        title:
          type: string
          description: calendar entries use the title
        vehicle_name:
          type: string
        vehicle_license:
          type: string
        created_at:
          type: string
          format: date-time
        total:
          type: number
    CustomerMessageSendRequest:
      type: object
      description: |
        CustomerMessageSendRequest — POST /v1/customers/{id}/messages.
      required:
        - type
        - entity_id
        - channel
        - recipient
        - subject
        - message
        - sender_method
      properties:
        type:
          type: string
          description: "worksheet | invoice | quote | calendar | revisione | service | freetext"
        entity_id:
          type: string
        channel:
          type: string
          description: "email | sms | whatsapp"
        recipient:
          type: string
        subject:
          type: string
        message:
          type: string
        sender_method:
          type: string
    DeliveryNote:
      type: object
      description: |
        DeliveryNote is one "Documenti di trasporto" (DDT) row. The list
        (GET /v1/delivery-notes) mirrors the web delivery-note-list: the
        supplier (or the causale/description when no supplier), the
        number, the delivery date and the amount. status is "draft"
        (Bozza) or "issued" (Emesso).
      required:
        - object
        - id
        - seq_number
        - supplier_id
        - supplier_name
        - description
        - status
        - delivery_date
        - total_before_tax
        - total_tax
        - total_after_tax
        - created_at
      properties:
        object:
          type: string
          description: "always \"delivery_note\""
        id:
          type: string
        seq_number:
          type: integer
        supplier_id:
          type: string
        supplier_name:
          type: string
        description:
          type: string
        status:
          type: string
        delivery_date:
          type: [string, null]
          format: date-time
        total_before_tax:
          type: number
        total_tax:
          type: number
        total_after_tax:
          type: number
        created_at:
          type: string
          format: date-time
    DeliveryNoteStatusCounts:
      type: object
      description: |
        DeliveryNoteStatusCounts backs the DDT status tabs (Bozza /
        Emessi). Counts are scoped to the sticky date range (echoed in
        date_filter), the free-text search and the selected supplier — web
        parity.
      required:
        - object
        - draft
        - issued
        - date_filter
      properties:
        object:
          type: string
          description: "always \"delivery_note_status_counts\""
        draft:
          type: integer
        issued:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    ElectronicInvoice:
      type: object
      description: |
        ElectronicInvoice — one outgoing (attiva) SDI transmission row.
        The list is a UNION of fatture (TD01), note di credito (TD04) and
        scontrini, so the client routes each row by document_type (see the
        mobile trasmissioni-sdi list): TD01 → the e-invoice detail (this
        ID), TD04 → the credit-note detail (CreditNoteID), scontrino → the
        receipt detail (this ID = receipt id).
      required:
        - object
        - id
        - status
        - document_type
        - invoice_id
        - credit_note_id
        - seq_number
        - customer_name
        - vehicle_name
        - total_after_discount
        - date
        - sent_at
        - delivered_at
        - rejected_at
        - created_at
      properties:
        object:
          type: string
          description: "always \"electronic_invoice\""
        id:
          type: string
        status:
          type: string
          description: "draft | sent | delivered | rejected | cancelled"
        document_type:
          type: string
          description: "TD01 (fattura) | TD04 (nota di credito) | scontrino"
        invoice_id:
          type: [string, null]
        credit_note_id:
          type: [string, null]
          description: "TD04 rows only: the credit-note detail id"
        receipt_number:
          type: [string, null]
          description: "scontrino rows: AdE document number, used as title when no customer"
        seq_number:
          type: integer
        customer_name:
          type: string
        vehicle_name:
          type: string
        total_after_discount:
          type: number
        date:
          type: [string, null]
          format: date-time
        sent_at:
          type: [string, null]
          format: date-time
        delivered_at:
          type: [string, null]
          format: date-time
        rejected_at:
          type: [string, null]
          format: date-time
        created_at:
          type: string
          format: date-time
    ElectronicInvoiceIncoming:
      type: object
      description: |
        ElectronicInvoiceIncoming — one passive supplier invoice from
        ACube.
      required:
        - object
        - id
        - supplier_id
        - business_name
        - invoice_number
        - invoice_date
        - total_amount
        - status
        - document_type
        - expense_id
        - created_at
      properties:
        object:
          type: string
          description: "always \"electronic_invoice_incoming\""
        id:
          type: string
        supplier_id:
          type: [string, null]
        business_name:
          type: string
        invoice_number:
          type: [string, null]
        invoice_date:
          type: string
          format: date-time
        total_amount:
          type: number
        status:
          type: string
          description: "received | quarantena"
        document_type:
          type: string
        expense_id:
          type: [string, null]
        created_at:
          type: string
          format: date-time
    ElectronicInvoiceIncomingStatusCounts:
      type: object
      description: |
        ElectronicInvoiceIncomingStatusCounts — Passive tab counters.
        Echoes the applied date filter (see
        ElectronicInvoiceStatusCounts).
      required:
        - object
        - received
        - quarantena
        - date_filter
      properties:
        object:
          type: string
          description: "always \"electronic_invoice_incoming_status_counts\""
        received:
          type: integer
        quarantena:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    ElectronicInvoiceStatusCounts:
      type: object
      description: |
        ElectronicInvoiceStatusCounts — Attive tab counters. Echoes the
        applied date filter so the client can show the sticky period
        without a probe.
      required:
        - object
        - draft
        - sent
        - delivered
        - rejected
        - cancelled
        - date_filter
      properties:
        object:
          type: string
          description: "always \"electronic_invoice_status_counts\""
        draft:
          type: integer
        sent:
          type: integer
        delivered:
          type: integer
        rejected:
          type: integer
        cancelled:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    EmailSender:
      type: object
      description: |
        EmailSender: one configured sending identity — the same options
        the web "Mittente" dropdown offers. "internal" is always present;
        "smtp" and "gmail" appear only when configured (in the web
        settings — configuration itself is not exposed on this API).
      required:
        - key
        - label
        - default
      properties:
        key:
          type: string
          description: "internal | smtp | gmail"
        label:
          type: string
          description: "e.g. \"Account Gmail (x@gmail.com)\""
        default:
          type: boolean
    Expense:
      type: object
      description: |
        Expense (spesa) — passive invoices from suppliers.
      required:
        - object
        - id
        - seq_number
        - supplier_id
        - supplier_name
        - expense_category_id
        - category_name
        - description
        - invoice_number
        - comments
        - status
        - expired
        - payment_method
        - payment_due_at
        - total_before_tax
        - total_tax
        - total_after_tax
        - total_discount
        - created_by_full_name
        - created_at
        - updated_at
        - deleted_at
      properties:
        object:
          type: string
          description: "always \"expense\""
        id:
          type: string
        seq_number:
          type: integer
        supplier_id:
          type: string
        supplier_name:
          type: string
        expense_category_id:
          type: [string, null]
        category_name:
          type: [string, null]
        description:
          type: [string, null]
        invoice_number:
          type: [string, null]
        comments:
          type: [string, null]
          description: "\"to_pay\" | \"paid\"; expired = to_pay past the due date."
        status:
          type: string
        expired:
          type: boolean
        payment_method:
          type: [string, null]
        payment_due_at:
          type: [string, null]
          format: date-time
        total_before_tax:
          type: number
        total_tax:
          type: number
        total_after_tax:
          type: number
        total_discount:
          type: number
        created_by_full_name:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: [string, null]
          format: date-time
          description: Detail responses only.
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/ExpenseLineItem'
        installments:
          type: array
          items:
            $ref: '#/components/schemas/ExpenseInstallment'
          description: Detail responses only — web expense-detail parity. TotalPayments /
        total_payments:
          type: [number, null]
        outstanding_payments:
          type: [number, null]
        is_stornata:
          type: [boolean, null]
        schedule:
          $ref: '#/components/schemas/ExpenseScheduleInfo'
        recurring_expenses:
          type: array
          items:
            $ref: '#/components/schemas/ExpenseRecurringRow'
        incoming_sdi:
          $ref: '#/components/schemas/ExpenseIncomingSDIInfo'
    ExpenseCategory:
      type: object
      description: |
        ExpenseCategory is one spese category option.
      required:
        - object
        - id
        - name
      properties:
        object:
          type: string
          description: "always \"expense_category\""
        id:
          type: string
        name:
          type: string
    ExpenseCreateRequest:
      type: object
      description: |
        ExpenseCreateRequest — the simplified mobile create: totals
        entered directly (line items are a web-side refinement).
      required:
        - supplier_id
        - description
        - invoice_number
        - expense_category_id
        - payment_method
        - payment_due_at
        - total_before_tax
        - total_tax
        - comments
      properties:
        supplier_id:
          type: string
        description:
          type: [string, null]
        invoice_number:
          type: [string, null]
        expense_category_id:
          type: [string, null]
        payment_method:
          type: [string, null]
        payment_due_at:
          type: [string, null]
          format: date-time
        total_before_tax:
          type: number
        total_tax:
          type: number
        comments:
          type: [string, null]
    ExpenseIncomingSDIInfo:
      type: object
      description: |
        ExpenseIncomingSDIInfo — the ricezione SDI this expense was
        created from.
      required:
        - object
        - id
        - invoice_number
      properties:
        object:
          type: string
          description: "always \"electronic_invoice_incoming_ref\""
        id:
          type: string
        invoice_number:
          type: [string, null]
    ExpenseInstallment:
      type: object
      description: |
        ExpenseInstallment is one planned scadenza.
      required:
        - object
        - id
        - due_date
        - amount
        - paid
      properties:
        object:
          type: string
          description: "always \"expense_installment\""
        id:
          type: string
        due_date:
          type: string
          format: date-time
        amount:
          type: number
        paid:
          type: boolean
    ExpenseLineItem:
      type: object
      required:
        - object
        - id
        - description
        - quantity
        - unit_type
        - unit_price
        - unit_discount_rate
        - tax_rate
        - total_price
      properties:
        object:
          type: string
          description: "always \"expense_line_item\""
        id:
          type: string
        description:
          type: string
        quantity:
          type: number
        unit_type:
          type: string
        unit_price:
          type: number
        unit_discount_rate:
          type: number
        tax_rate:
          type: number
        total_price:
          type: number
    ExpensePatchRequest:
      type: object
      required:
        - description
        - invoice_number
        - expense_category_id
        - payment_method
        - payment_due_at
        - total_before_tax
        - total_tax
        - comments
      properties:
        description:
          description: Free-form value.
        invoice_number:
          description: Free-form value.
        expense_category_id:
          description: Free-form value.
        payment_method:
          description: Free-form value.
        payment_due_at:
          description: Free-form value.
        total_before_tax:
          description: Free-form value.
        total_tax:
          description: Free-form value.
        comments:
          description: Free-form value.
    ExpensePayment:
      type: object
      description: |
        ExpensePayment — one row of the expense payments ledger.
      required:
        - object
        - id
        - amount
        - payment_method
        - payment_date
        - financial_account_id
        - created_at
      properties:
        object:
          type: string
          description: "always \"expense_payment\""
        id:
          type: string
        amount:
          type: number
        payment_method:
          type: string
        payment_date:
          type: string
          format: date-time
        financial_account_id:
          type: [string, null]
        created_at:
          type: string
          format: date-time
    ExpensePaymentCreateRequest:
      type: object
      description: |
        ExpensePaymentCreateRequest — POST /v1/expenses/{id}/payments.
      required:
        - amount
        - payment_method
        - payment_date
        - financial_account_id
      properties:
        amount:
          type: number
        payment_method:
          type: [string, null]
        payment_date:
          type: [string, null]
          description: YYYY-MM-DD, default today
        financial_account_id:
          type: [string, null]
    ExpenseRecurringRow:
      type: object
      description: |
        ExpenseRecurringRow — sibling expense generated by the same
        schedule.
      required:
        - object
        - id
        - seq_number
        - created_at
        - status
        - total_after_tax
      properties:
        object:
          type: string
          description: "always \"expense_recurring_row\""
        id:
          type: string
        seq_number:
          type: integer
        created_at:
          type: string
          format: date-time
        status:
          type: string
        total_after_tax:
          type: number
    ExpenseScheduleInfo:
      type: object
      description: |
        ExpenseScheduleInfo — the recurring schedule this expense belongs
        to.
      required:
        - object
        - id
        - frequency_months
      properties:
        object:
          type: string
          description: "always \"expense_schedule\""
        id:
          type: string
        frequency_months:
          type: integer
    ExpenseStatusCounts:
      type: object
      description: |
        ExpenseStatusCounts backs the list tabs (GET
        /v1/expenses/status-counts).
      required:
        - object
        - to_pay
        - expired
        - paid
        - date_filter
      properties:
        object:
          type: string
          description: "always \"expense_status_counts\""
        to_pay:
          type: integer
        expired:
          type: integer
        paid:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    FinancialAccount:
      type: object
      description: |
        FinancialAccount: one of the company's conti (Banca, Cassa, …) —
        the options of the prima nota Conto filter, mirroring the web
        cashbook's account dropdown. Read-only on this API.
      required:
        - object
        - id
        - type
        - name
      properties:
        object:
          type: string
          description: "always \"financial_account\""
        id:
          type: string
        type:
          type: string
          description: "e.g. \"bank\" | \"cash\""
        name:
          type: string
    LicensePlateLookupRequest:
      type: object
      description: |
        LicensePlateLookupRequest: body of POST
        /v1/vehicles/license-plate-lookup.
      required:
        - license_plate
      properties:
        license_plate:
          type: string
    LogoutRequest:
      type: object
      description: |
        LogoutRequest: POST /v1/auth/logout body. The refresh token
        identifies the session to revoke; sending it (rather than relying
        on the bearer) lets sign-out work even after the short-lived
        access token has expired.
      required:
        - refresh_token
      properties:
        refresh_token:
          type: string
    ManufacturerListEnvelope:
      type: object
      description: |
        ManufacturerListEnvelope: success body for GET /v1/manufacturers.
        Follows the standard list envelope; the collection is unpaginated
        (small, rarely-changing catalog) so HasMore is always false.
      required:
        - object
        - data
        - has_more
        - url
      properties:
        object:
          type: string
          description: "always \"list\""
        data:
          type: array
          items:
            $ref: '#/components/schemas/Manufacturer'
        has_more:
          type: boolean
        url:
          type: string
    Order:
      type: object
      description: |
        Order is one "Arrivi Merce" row — a supplier order (ordine
        fornitore). List responses carry the header fields only; line
        items live on the (not-yet-built) detail endpoint. status is one
        of the web lifecycle values: "pending" (In attesa) / "delivery"
        (In consegna) / "received" (Ricevuto).
      required:
        - object
        - id
        - seq_number
        - supplier_id
        - supplier_name
        - supplier_invoice_reference
        - status
        - total_before_tax
        - total_tax
        - total_after_tax
        - total_after_discount
        - payment_method
        - comments
        - invoice_date
        - confirmed_at
        - received_at
        - created_at
      properties:
        object:
          type: string
          description: "always \"order\""
        id:
          type: string
        seq_number:
          type: integer
        supplier_id:
          type: string
        supplier_name:
          type: string
        supplier_invoice_reference:
          type: [string, null]
        status:
          type: string
        total_before_tax:
          type: number
        total_tax:
          type: number
        total_after_tax:
          type: number
        total_after_discount:
          type: number
        payment_method:
          type: string
        comments:
          type: [string, null]
        invoice_date:
          type: [string, null]
          format: date-time
        confirmed_at:
          type: [string, null]
          format: date-time
        received_at:
          type: [string, null]
          format: date-time
        created_at:
          type: string
          format: date-time
    OrderStatusCounts:
      type: object
      description: |
        OrderStatusCounts backs the "Arrivi Merce" status tabs. Counts are
        scoped to the sticky date range (echoed back in date_filter) and
        the free-text search, mirroring the web list's per-status tab
        counters.
      required:
        - object
        - pending
        - delivery
        - received
        - date_filter
      properties:
        object:
          type: string
          description: "always \"order_status_counts\""
        pending:
          type: integer
        delivery:
          type: integer
        received:
          type: integer
        date_filter:
          $ref: '#/components/schemas/DateFilter'
    PaymentListEnvelope:
      type: object
      description: |
        PaymentListEnvelope: success body for GET
        /v1/worksheets/{id}/payments. Unpaginated — a worksheet carries a
        handful of payments at most.
      required:
        - object
        - data
        - has_more
        - url
      properties:
        object:
          type: string
          description: "always \"list\""
        data:
          type: array
          items:
            $ref: '#/components/schemas/Payment'
        has_more:
          type: boolean
        url:
          type: string
    ProfileModules:
      type: object
      description: |
        ProfileModules: the company feature flags the app UI gates on,
        resolved server-side from the company row with the same semantics
        the web sidebar reads them. technical_data is the composite
        Company.CanUseTechnicalData() (module flag AND active
        subscription); has_worksheets backs the web's "Situazione appears
        after the first worksheet" rule.
      required:
        - checkins
        - electronic_invoices
        - expenses
        - dealership
        - vehicle_rental
        - tyre_storage
        - service_catalog
        - inventory
        - technical_data
        - license_plate_search
        - public_profile
        - ai_assistant
        - has_worksheets
      properties:
        checkins:
          type: boolean
        electronic_invoices:
          type: boolean
        expenses:
          type: boolean
        dealership:
          type: boolean
        vehicle_rental:
          type: boolean
        tyre_storage:
          type: boolean
        service_catalog:
          type: boolean
        inventory:
          type: boolean
        technical_data:
          type: boolean
        license_plate_search:
          type: boolean
        public_profile:
          type: boolean
        ai_assistant:
          type: boolean
        has_worksheets:
          type: boolean
    ProfileUpdateRequest:
      type: object
      description: |
        ProfileUpdateRequest: body of PATCH /v1/me. first_name / last_name
        required (web parity) phone required — Italian mobile, national
        number or full +39 form (NormalizeItalianMobilePhone) password
        optional — set only to change it (min 6 chars, web parity); the
        repeat-password check is client-side, like the web form
      required:
        - first_name
        - last_name
        - phone
        - password
      properties:
        first_name:
          type: string
        last_name:
          type: string
        phone:
          type: string
        password:
          type: string
    Receipt:
      type: object
      description: |
        Receipt is the serialized form of entity.Receipt for the
        /v1/receipts endpoints. Same conventions as Invoice: money floats
        are display values, computed names are inlined, line_items appear
        on detail responses only.
      required:
        - object
        - id
        - title
        - comments
        - internal_comments
        - email
        - customer_id
        - vehicle_id
        - worksheet_id
        - sezionale_id
        - seq_number
        - type
        - status
        - issued_at
        - voided_at
        - payment_method
        - total_before_tax
        - total_tax
        - total_after_tax
        - total_discount
        - total_after_discount
        - total_payments
        - outstanding_payments
        - customer_name
        - vehicle_name
        - vehicle_license
        - created_by_full_name
        - sezionale_code
        - e_receipt_status
        - e_receipt_document_number
        - electronic_receipts_enabled
        - created_at
        - updated_at
        - deleted_at
      properties:
        object:
          type: string
          description: "always \"receipt\""
        id:
          type: string
        title:
          type: [string, null]
        comments:
          type: [string, null]
        internal_comments:
          type: [string, null]
          description: Optional — the electronic receipt PDF is delivered here.
        email:
          type: [string, null]
        customer_id:
          type: [string, null]
        vehicle_id:
          type: [string, null]
        worksheet_id:
          type: [string, null]
        sezionale_id:
          type: [string, null]
          description: 0 until issued.
        seq_number:
          type: integer
          description: "\"sale\" | \"return\"."
        type:
          type: string
          description: "Derived: \"draft\" | \"issued\" | \"voided\"."
        status:
          type: string
        issued_at:
          type: [string, null]
          format: date-time
        voided_at:
          type: [string, null]
          format: date-time
        payment_method:
          type: [string, null]
        total_before_tax:
          type: number
        total_tax:
          type: number
        total_after_tax:
          type: number
        total_discount:
          type: number
        total_after_discount:
          type: number
        total_payments:
          type: number
        outstanding_payments:
          type: number
        customer_name:
          type: [string, null]
        vehicle_name:
          type: [string, null]
        vehicle_license:
          type: [string, null]
        created_by_full_name:
          type: string
        sezionale_code:
          type: [string, null]
          description: Latest electronic-receipt transmission (empty when none / module off).
        e_receipt_status:
          type: [string, null]
        e_receipt_document_number:
          type: [string, null]
          description: ElectronicReceiptsEnabled — the company transmits scontrini to the AdE.
        electronic_receipts_enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: [string, null]
          format: date-time
          description: Detail responses only.
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/ReceiptLineItem'
    ReceiptCreateRequest:
      type: object
      description: |
        ReceiptCreateRequest — a mobile scontrino is always created from a
        worksheet (web "Crea scontrino" parity).
      required:
        - worksheet_id
        - payment_method
      properties:
        worksheet_id:
          type: string
        payment_method:
          type: string
    ReceiptLineItem:
      type: object
      required:
        - object
        - id
        - article_id
        - description
        - quantity
        - unit_type
        - unit_price
        - unit_discount_rate
        - tax_rate
        - vat_code
        - total_price
        - created_at
      properties:
        object:
          type: string
          description: "always \"receipt_line_item\""
        id:
          type: string
        article_id:
          type: [string, null]
        description:
          type: string
        quantity:
          type: number
        unit_type:
          type: string
        unit_price:
          type: number
        unit_discount_rate:
          type: number
        tax_rate:
          type: number
        vat_code:
          type: [string, null]
        total_price:
          type: number
        created_at:
          type: string
          format: date-time
    ReceiptPatchRequest:
      type: object
      required:
        - title
        - comments
        - internal_comments
        - email
        - payment_method
        - sezionale_id
      properties:
        title:
          description: Free-form value.
        comments:
          description: Free-form value.
        internal_comments:
          description: Free-form value.
        email:
          description: Free-form value.
        payment_method:
          description: Free-form value.
        sezionale_id:
          description: Free-form value.
    ReceiptStatusCounts:
      type: object
      description: |
        ReceiptStatusCounts — the scontrini list document-status tab
        counters (Bozze / Emessi / Annullati), computed over the active
        SDI + search filters, web parity.
      required:
        - object
        - draft
        - issued
        - voided
      properties:
        object:
          type: string
          description: "always \"receipt_status_counts\""
        draft:
          type: integer
        issued:
          type: integer
        voided:
          type: integer
    RefreshRequest:
      type: object
      description: |
        RefreshRequest: POST /v1/auth/refresh body. The opaque refresh
        token IS the credential (this endpoint takes no bearer), exchanged
        for a rotated token pair.
      required:
        - refresh_token
      properties:
        refresh_token:
          type: string
    ReportAppointment:
      type: object
      description: |
        ReportAppointment is one row of widget "upcoming-appointments".
      required:
        - id
        - title
        - customer_name
        - start_time
      properties:
        id:
          type: string
        title:
          type: string
        customer_name:
          type: string
        start_time:
          type: string
          description: RFC 3339
    ReportChartSeries:
      type: object
      description: |
        ReportChartSeries is one named series of a stacked chart, aligned
        with the envelope's XAxis.
      required:
        - key
        - values
      properties:
        key:
          type: string
        values:
          type: array
          items:
            type: number
    ReportKPI:
      type: object
      description: |
        ReportKPI is one headline stat tile (widget "kpis" returns four).
      required:
        - key
        - value
        - is_currency
        - previous_value
        - delta
        - delta_percentage
      properties:
        key:
          type: string
          description: "sales | outstanding | worksheets | customers"
        value:
          type: number
        is_currency:
          type: boolean
        previous_value:
          type: number
        delta:
          type: number
        delta_percentage:
          type: number
    ReportOutstandingWorksheet:
      type: object
      description: |
        ReportOutstandingWorksheet is one row of widget
        "outstanding-worksheets". BarWidth is the server-normalized bar
        length (0-100).
      required:
        - worksheet_id
        - title
        - customer_name
        - outstanding
        - total
        - bar_width
      properties:
        worksheet_id:
          type: string
        title:
          type: string
        customer_name:
          type: string
        outstanding:
          type: number
        total:
          type: number
        bar_width:
          type: number
    ReportRankedRow:
      type: object
      description: |
        ReportRankedRow is one bar of the ranked-list widgets
        ("top-customers", "top-services", "worksheet-margins"). Amount
        carries the sort metric; BarWidth is server-normalized (0-100).
      required:
        - id
        - label
        - amount
        - bar_width
      properties:
        id:
          type: string
        label:
          type: string
        sublabel:
          type: string
        amount:
          type: number
        percent:
          type: number
        bar_width:
          type: number
    RoadmapBoard:
      type: object
      description: |
        Roadmap (Novità / Funzionalità in sviluppo) — web
        settings-roadmap.
      required:
        - object
        - planned
        - progress
        - done
      properties:
        object:
          type: string
          description: "always \"roadmap_board\""
        planned:
          type: array
          items:
            $ref: '#/components/schemas/RoadmapItem'
        progress:
          type: array
          items:
            $ref: '#/components/schemas/RoadmapItem'
        done:
          type: array
          items:
            $ref: '#/components/schemas/RoadmapItem'
    RoadmapItem:
      type: object
      required:
        - object
        - id
        - title
        - description
        - votes
        - has_voted
      properties:
        object:
          type: string
          description: "always \"roadmap_item\""
        id:
          type: string
        title:
          type: string
        description:
          type: string
          description: Display count (baseline 1 + user votes, web DisplayVotes).
        votes:
          type: integer
        has_voted:
          type: boolean
    RoadmapSuggestionRequest:
      type: object
      required:
        - name
        - description
      properties:
        name:
          type: string
        description:
          type: string
    SendChannelEmail:
      type: object
      description: |
        SendChannelEmail: defaults for the email form. Free (no credits).
      required:
        - recipient
        - subject
        - body
        - senders
      properties:
        recipient:
          type: string
          description: "customer email (\"\" when unset)"
        subject:
          type: string
          description: max 50 chars on send
        body:
          type: string
          description: editable, share link included
        senders:
          type: array
          items:
            $ref: '#/components/schemas/EmailSender'
          description: exactly one has default=true
    SendChannelSMS:
      type: object
      description: |
        SendChannelSMS: defaults for the SMS form. Body must stay within
        max_length and the GSM-7 basic charset (no accents/emoji).
        Recipient is the customer's stored E.164 phone: messaging is
        Italian-only, so the client shows an editable +39 national-number
        field when it's empty or +39, and a read-only field with a
        disabled send otherwise (web message-phone-field parity).
      required:
        - recipient
        - body
        - max_length
        - credit_cost
      properties:
        recipient:
          type: string
          description: "customer phone (\"\" when unset)"
        body:
          type: string
        max_length:
          type: integer
          description: 160
        credit_cost:
          type: number
          description: 0 = included in the plan
    SendChannelWhatsApp:
      type: object
      description: |
        SendChannelWhatsApp: the WhatsApp Business flow sends a
        pre-approved template — template_preview is display-only, never
        editable or submitted. Same Italian-only recipient rules as SMS.
      required:
        - recipient
        - template_preview
        - credit_cost
      properties:
        recipient:
          type: string
        template_preview:
          type: string
        credit_cost:
          type: number
          description: 0 = included in the plan
    Session:
      type: object
      description: |
        Session: one entry in the "Sessioni attive" list (GET
        /v1/sessions) and the object returned by DELETE /v1/sessions/{id}.
        Never exposes the token hash or raw IP — only what the user needs
        to recognise and manage a device.
      required:
        - object
        - id
        - surface
        - display_name
        - created_at
        - last_used_at
        - active
        - current
      properties:
        object:
          type: string
          description: "always \"session\""
        id:
          type: string
        surface:
          type: string
          description: "web | mobile"
        display_name:
          type: string
        email:
          type: string
          description: team member the device belongs to (owner-only company listing)
        platform:
          type: string
        app_version:
          type: string
        os_version:
          type: string
        location:
          type: string
          description: "\"Milano, IT\" — from Cloudflare geo"
        last_ip:
          type: string
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
        active:
          type: boolean
          description: true when the token is still live (not revoked/expired)
        current:
          type: boolean
          description: "true on the caller's own session"
    SessionDeletedResponse:
      type: object
      description: |
        SessionDeletedResponse: body for DELETE /v1/sessions/{id} and POST
        /v1/auth/logout.
      required:
        - object
        - id
        - deleted
      properties:
        object:
          type: string
          description: "always \"session\""
        id:
          type: string
        deleted:
          type: boolean
    SessionRevocationResponse:
      type: object
      description: |
        SessionRevocationResponse: body for POST
        /v1/sessions/revoke-others.
      required:
        - object
        - revoked_count
      properties:
        object:
          type: string
          description: "always \"session_revocation\""
        revoked_count:
          type: integer
    Supplier:
      type: object
      description: |
        Supplier (fornitore).
      required:
        - object
        - id
        - name
        - email
        - phone
        - phone_national
        - address
        - city
        - zip_code
        - tax_id
        - comments
        - bank_account_iban
        - website
        - website_display
        - supplier_type
        - created_at
        - updated_at
        - deleted_at
      properties:
        object:
          type: string
          description: "always \"supplier\""
        id:
          type: string
        name:
          type: string
        email:
          type: [string, null]
        phone:
          type: [string, null]
          description: "PhoneNational is Phone without the +39 country code, for an inset-\"+39\""
        phone_national:
          type: [string, null]
        address:
          type: [string, null]
        city:
          type: [string, null]
        zip_code:
          type: [string, null]
        tax_id:
          type: [string, null]
        comments:
          type: [string, null]
        bank_account_iban:
          type: [string, null]
        website:
          type: [string, null]
          description: "WebsiteDisplay is Website without the scheme, for an inset-\"https://\" edit"
        website_display:
          type: [string, null]
        supplier_type:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: [string, null]
          format: date-time
          description: Detail-only compact rows for the web supplier-detail tabs
        orders:
          type: array
          items:
            $ref: '#/components/schemas/SupplierOrderRow'
        articles:
          type: array
          items:
            $ref: '#/components/schemas/SupplierArticleRow'
        expenses:
          type: array
          items:
            $ref: '#/components/schemas/SupplierExpenseRow'
    SupplierArticleRow:
      type: object
      required:
        - object
        - id
        - code
        - seq_number
        - name
        - stock
        - sale_price
        - created_at
      properties:
        object:
          type: string
          description: "always \"supplier_article\""
        id:
          type: string
        code:
          type: [string, null]
        seq_number:
          type: integer
        name:
          type: string
        stock:
          type: number
        sale_price:
          type: [number, null]
        created_at:
          type: string
          format: date-time
    SupplierCreateRequest:
      type: object
      required:
        - name
        - email
        - phone
        - address
        - city
        - zip_code
        - tax_id
        - comments
        - bank_account_iban
        - website
      properties:
        name:
          type: string
        email:
          type: [string, null]
        phone:
          type: [string, null]
        address:
          type: [string, null]
        city:
          type: [string, null]
        zip_code:
          type: [string, null]
        tax_id:
          type: [string, null]
        comments:
          type: [string, null]
        bank_account_iban:
          type: [string, null]
        website:
          type: [string, null]
    SupplierExpenseRow:
      type: object
      required:
        - object
        - id
        - seq_number
        - created_at
        - invoice_number
        - payment_due_at
        - total_after_tax
        - status
      properties:
        object:
          type: string
          description: "always \"supplier_expense\""
        id:
          type: string
        seq_number:
          type: integer
        created_at:
          type: string
          format: date-time
        invoice_number:
          type: [string, null]
        payment_due_at:
          type: [string, null]
          format: date-time
        total_after_tax:
          type: number
        status:
          type: string
          description: "to_pay | paid"
    SupplierOrderRow:
      type: object
      required:
        - object
        - id
        - seq_number
        - created_at
        - status
        - total_after_tax
      properties:
        object:
          type: string
          description: "always \"supplier_order\""
        id:
          type: string
        seq_number:
          type: integer
        created_at:
          type: string
          format: date-time
        status:
          type: string
          description: "pending | delivery | received"
        total_after_tax:
          type: number
    SupplierPatchRequest:
      type: object
      required:
        - name
        - email
        - phone
        - address
        - city
        - zip_code
        - tax_id
        - comments
        - bank_account_iban
        - website
      properties:
        name:
          description: Free-form value.
        email:
          description: Free-form value.
        phone:
          description: Free-form value.
        address:
          description: Free-form value.
        city:
          description: Free-form value.
        zip_code:
          description: Free-form value.
        tax_id:
          description: Free-form value.
        comments:
          description: Free-form value.
        bank_account_iban:
          description: Free-form value.
        website:
          description: Free-form value.
    TyreSet:
      type: object
      description: |
        TyreSet (treno gomme) — deposito gomme.
      required:
        - object
        - id
        - seq_number
        - description
        - status
        - quantity
        - rim_type
        - season_type
        - condition
        - residual_mm
        - location
        - comments
        - customer_id
        - vehicle_id
        - customer_name
        - vehicle_name
        - vehicle_license
        - deposited_at
        - mounted_at
        - collected_at
        - disposed_at
        - inventory_check_at
        - created_at
        - updated_at
        - deleted_at
      properties:
        object:
          type: string
          description: "always \"tyre_set\""
        id:
          type: string
        seq_number:
          type: integer
        description:
          type: string
        status:
          type: string
          description: "deposited | mounted | disposed | collected"
        quantity:
          type: integer
        rim_type:
          type: string
          description: "steel | alloy"
        season_type:
          type: [string, null]
        condition:
          type: [string, null]
        residual_mm:
          type: [integer, null]
        location:
          type: [string, null]
        comments:
          type: [string, null]
        customer_id:
          type: string
        vehicle_id:
          type: string
        customer_name:
          type: string
        vehicle_name:
          type: string
        vehicle_license:
          type: [string, null]
        deposited_at:
          type: [string, null]
          format: date-time
        mounted_at:
          type: [string, null]
          format: date-time
        collected_at:
          type: [string, null]
          format: date-time
        disposed_at:
          type: [string, null]
          format: date-time
        inventory_check_at:
          type: [string, null]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        deleted_at:
          type: [string, null]
          format: date-time
    TyreSetCreateRequest:
      type: object
      description: |
        TyreSetCreateRequest doubles as the full-form PATCH body (customer
        and vehicle are ignored on update).
      required:
        - customer_id
        - vehicle_id
        - description
        - quantity
        - rim_type
        - season_type
        - condition
        - residual_mm
        - location
        - comments
      properties:
        customer_id:
          type: string
        vehicle_id:
          type: string
        description:
          type: string
        quantity:
          type: [integer, null]
        rim_type:
          type: string
        season_type:
          type: [string, null]
        condition:
          type: [string, null]
        residual_mm:
          type: [integer, null]
        location:
          type: [string, null]
        comments:
          type: [string, null]
    TyreSetStatusCounts:
      type: object
      required:
        - object
        - deposited
        - mounted
        - disposed
        - collected
        - locations
      properties:
        object:
          type: string
          description: "always \"tyre_set_status_counts\""
        deposited:
          type: integer
        mounted:
          type: integer
        disposed:
          type: integer
        collected:
          type: integer
          description: Distinct locations in use — drives the Ubicazione filter, which the
        locations:
          type: array
          items:
            type: string
    VatRateCatalogEntry:
      type: object
      description: |
        VatRateCatalogEntry — one global VAT rate with the company's
        enabled mark (GET /v1/vat-rates/catalog).
      required:
        - object
        - code
        - rate
        - description
        - enabled
      properties:
        object:
          type: string
          description: "always \"vat_rate_catalog_entry\""
        code:
          type: string
        rate:
          type: number
        description:
          type: [string, null]
        enabled:
          type: boolean
    VehicleCustomField:
      type: object
      description: |
        VehicleCustomField: one company-configured custom field resolved
        for display and editing — id from the definition, its display
        name, its input type (string | integer | datetime) and this
        vehicle's value ("" when unset).
      required:
        - id
        - name
        - type
        - value
      properties:
        id:
          type: string
        name:
          type: string
        type:
          type: string
        value:
          type: string
    VehicleFieldVisibility:
      type: object
      description: |
        VehicleFieldVisibility mirrors the company's optional-field
        toggles (entity.VehicleFieldVisibility) so the edit form hides
        exactly what the web form hides.
      required:
        - km
        - numero_telaio
        - transmission
        - engine_category
        - engine_power_kw
        - engine_displacement_ccm
        - vehicle_weight_kg
        - registration_date
        - last_inspection_date
        - next_inspection_date
      properties:
        km:
          type: boolean
        numero_telaio:
          type: boolean
        transmission:
          type: boolean
        engine_category:
          type: boolean
        engine_power_kw:
          type: boolean
        engine_displacement_ccm:
          type: boolean
        vehicle_weight_kg:
          type: boolean
        registration_date:
          type: boolean
        last_inspection_date:
          type: boolean
        next_inspection_date:
          type: boolean

  responses:

    Unauthorized:
      x-description-it: "Chiave API mancante, non valida, revocata o scaduta."
      description: Missing, malformed, expired, or wrong-audience bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/api_key_invalid
              status: 401
              code: api_key_invalid
              message: Chiave API mancante, non valida, revocata o scaduta.
              trace_id: abc-123

    PaymentRequired:
      x-description-it: "Il piano non include l'accesso API, oppure non è attivo un abbonamento."
      description: |
        The company has no active subscription, or its plan does not
        include API access. API access is included in the Business and
        Illimitato plans.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/plan_upgrade_required
              status: 402
              code: plan_upgrade_required
              message: Il piano attivo non include l'accesso API.
              trace_id: abc-123

    Forbidden:
      x-description-it: "La chiave non ha il permesso richiesto per questa operazione."
      description: The authenticated user lacks the required permission.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/insufficient_scope
              status: 403
              code: insufficient_scope
              message: La chiave non ha il permesso reports.read.
              trace_id: abc-123

    NotFound:
      x-description-it: "La risorsa non esiste, è stata eliminata o non appartiene alla tua officina."
      description: |
        Resource does not exist OR belongs to a different company.
        The two cases are intentionally indistinguishable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/not_found
              status: 404
              code: not_found
              message: Customer not found.
              trace_id: abc-123

    InvalidCredentials:
      x-description-it: "Credenziali non corrette."
      description: Email or password incorrect.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/invalid_credentials
              status: 401
              code: invalid_credentials
              message: The email or password you provided is incorrect.
              trace_id: abc-123

    SignOnTokenInvalid:
      x-description-it: "Codice di accesso non valido o scaduto."
      description: Sign-on token invalid or expired.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/sign_on_token_invalid
              status: 401
              code: sign_on_token_invalid
              message: The sign-on token is invalid or has expired.
              param: /sign_on_token
              trace_id: abc-123

    ValidationError:
      x-description-it: "Un campo della richiesta manca o non è valido: «param» indica quale."
      description: A request field is missing or invalid (fast-fail).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/parameter_invalid
              status: 422
              code: parameter_invalid
              message: Email must be a valid email address.
              param: /email
              trace_id: abc-123

    MalformedRequest:
      x-description-it: "Corpo della richiesta non leggibile o JSON non valido."
      description: Request body is unparseable or X-API-Version is missing.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/malformed_request
              status: 400
              code: malformed_request
              message: Request body is not valid JSON.
              trace_id: abc-123

    UnsupportedMediaType:
      x-description-it: "Il Content-Type deve essere application/json e Accept deve accettare JSON."
      description: Content-Type is not `application/json`, or Accept rejects JSON.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/unsupported_media_type
              status: 415
              code: unsupported_media_type
              message: Content-Type must be application/json.
              trace_id: abc-123

    Conflict:
      x-description-it: "L'operazione è in conflitto con lo stato attuale della risorsa."
      description: |
        The request conflicts with the current state of the
        resource. Examples: issuing an invoice that has already
        been issued; back-dating an issuance that would violate the
        monotonic per-company sequence; deleting a credit note
        whose SDI electronic counterpart has already been
        transmitted and cannot be retracted; mutating a worksheet
        whose linked invoice has been issued (the worksheet is
        frozen — its totals are now part of the issued invoice's
        accounting record and must not desync).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/conflict
              status: 409
              code: conflict
              message: "Worksheet is frozen: its linked invoice has been issued. Cancel the invoice with a credit note before editing."
              trace_id: abc-123

    InternalError:
      x-description-it: "Errore imprevisto del server. Il dettaglio finisce nei log, non nella risposta."
      description: Unhandled server error. Detail is logged, not exposed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: https://api.officina.it/errors/internal_error
              status: 500
              code: internal_error
              message: An unexpected error occurred. Please retry or contact support if it persists.
              trace_id: abc-123
