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