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. # ─────────────────────────────────────────────────────────────── # 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: true, description: "Veicoli, marche e documenti allegati."} - {slug: schede-lavoro, name: Schede lavoro, tags: [Worksheets], ready: true, description: "Schede, righe, pagamenti e stati di lavorazione."} - {slug: preventivi, name: Preventivi, tags: [Quotes], ready: true, description: "Preventivi, righe e conversione in scheda lavoro."} - {slug: fatture, name: Fatture, tags: [Invoices, CreditNotes], ready: true, description: "Fatture, note di credito, pagamenti ed emissione."} - {slug: scontrini, name: Scontrini, tags: [Receipts], ready: true, description: "Scontrini e relativi pagamenti."} - {slug: accettazioni, name: Accettazioni, tags: [Checkins], ready: true, description: "Accettazioni, conversioni e documenti."} - {slug: agenda, name: Agenda, tags: [Calendar], ready: true, description: "Calendari e appuntamenti."} - {slug: magazzino, name: Magazzino, tags: [Articles], ready: true, description: "Articoli, listino manodopera e movimenti."} - {slug: deposito-gomme, name: Deposito gomme, tags: [TyreSets], ready: true, description: "Pneumatici in deposito e passaggi di stato."} - {slug: fornitori, name: Fornitori, tags: [Suppliers], ready: true, description: "Fornitori, ordini e documenti di trasporto."} - {slug: spese, name: Spese, tags: [Expenses], ready: true, description: "Spese, pagamenti e categorie."} - {slug: prima-nota, name: Prima nota, tags: [CashBook], ready: true, description: "Movimenti di cassa e conti finanziari."} - {slug: impostazioni, name: Impostazioni, tags: [Settings], ready: true, description: "Dati azienda, aliquote IVA, listini, collaboratori."} - {slug: fatturazione-elettronica, name: Fatturazione elettronica, tags: [ElectronicInvoicing], ready: true, description: "Fatture elettroniche emesse e ricevute, in sola lettura."} - {slug: concessionaria, name: Concessionaria, tags: [Dealership], ready: true, description: "Stock veicoli, vendite e report."} - {slug: report, name: Report, tags: [Reports], ready: true, description: "Statistiche e contatori della tua officina."} - {slug: autenticazione, name: Autenticazione, tags: [Authentication], ready: true, description: "Verifica della chiave e dei permessi."} paths: /v1/articles: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Articles] summary: Search the article/labor catalog x-summary-it: "Cerca nel magazzino e nel listino" x-scope: articles.read x-description-it: | Il catalogo dietro «Cerca da magazzino». Contiene sia i ricambi (`kind: item`) sia le voci di manodopera (`kind: work`). Ogni parola di `q` deve comparire nel nome, nel codice o nelle note. Filtra per tipo con `kind`. Passa `customer_id` per ottenere il `sale_price` già scontato secondo il listino di quel cliente. 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 } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } responses: '200': description: One page of catalog rows. x-description-it: "Gli articoli che soddisfano la ricerca." 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 } example: object: list data: - object: article id: f18c4b62-9d07-4e53-a294-6b1f8d3c5e70 code: FO-2231 name: Filtro olio kind: item unit_type: pz stock: 12 sale_price: 14.9 vat_code: '22' unit_discount_rate: 0 - object: article id: 2c9e5a71-4b38-40df-9126-7e3d1f6a8c25 code: null name: Manodopera meccanica kind: work unit_type: h stock: 0 sale_price: 40.0 vat_code: '22' unit_discount_rate: 0 has_more: false url: /v1/articles '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Articles] summary: Create an article x-summary-it: "Crea un articolo" x-scope: articles.write x-description-it: | Crea un ricambio o una voce di manodopera. `unit_type` deve essere coerente con il tipo: `kg`, `L`, `kit`, `pz` per il magazzino; `gg`, `h`, `lav`, `min` per la manodopera. `vat_code` deve essere una delle aliquote attive dell'azienda (vedi `GET /v1/vat-rates`). operationId: articleCreate requestBody: required: true content: application/json: schema: type: object required: [name, unit_type, vat_code] properties: name: { type: string } code: { type: [string, 'null'] } unit_type: type: string description: | Magazzino: `kg`, `L`, `kit`, `pz`. Manodopera: `gg`, `h`, `lav`, `min`. stock: { type: [number, 'null'] } minimum_stock: { type: [number, 'null'] } purchase_price: { type: [number, 'null'] } sale_price: { type: [number, 'null'] } vat_code: type: string description: Deve essere una delle aliquote attive dell'azienda. unit_discount_rate: { type: [integer, 'null'] } location: { type: [string, 'null'] } comments: { type: [string, 'null'] } supplier_id: { type: [string, 'null'] } example: name: Filtro olio code: FO-2231 unit_type: pz stock: 12 purchase_price: 6.4 sale_price: 14.9 vat_code: '22' supplier_id: null responses: '201': description: success x-description-it: "L'articolo creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: article id: f18c4b62-9d07-4e53-a294-6b1f8d3c5e70 code: FO-2231 name: Filtro olio kind: item unit_type: pz stock: 12 sale_price: 14.9 vat_code: '22' unit_discount_rate: 0 minimum_stock: 4 purchase_price: 6.4 location: Scaffale A2 comments: Compatibile con motori 1.2 FIRE supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un articolo" x-scope: articles.write x-description-it: | Eliminazione logica: l'articolo sparisce dal catalogo. Le righe dei documenti che lo citano restano intatte. operationId: articleDelete responses: '200': description: success x-description-it: "L'articolo eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: article id: f18c4b62-9d07-4e53-a294-6b1f8d3c5e70 code: FO-2231 name: Filtro olio kind: item unit_type: pz stock: 12 sale_price: 14.9 vat_code: '22' unit_discount_rate: 0 minimum_stock: 4 purchase_price: 6.4 location: Scaffale A2 comments: Compatibile con motori 1.2 FIRE supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } get: tags: [Articles] summary: Retrieve a article x-summary-it: "Recupera un articolo" x-scope: articles.read x-description-it: | Restituisce l'articolo con i campi di dettaglio che l'elenco omette: soglia di riordino, prezzo di acquisto, posizione, note e fornitore. operationId: articleGet responses: '200': description: success x-description-it: "L'articolo richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: article id: f18c4b62-9d07-4e53-a294-6b1f8d3c5e70 code: FO-2231 name: Filtro olio kind: item unit_type: pz stock: 12 sale_price: 14.9 vat_code: '22' unit_discount_rate: 0 minimum_stock: 4 purchase_price: 6.4 location: Scaffale A2 comments: Compatibile con motori 1.2 FIRE supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Articles] summary: Update article x-summary-it: "Aggiorna un articolo" x-scope: articles.write x-description-it: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. I campi omessi vengono azzerati, non lasciati invariati. operationId: articlePatch description: | (the mobile edit sends every field, matching the web editor). requestBody: required: true description: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. content: application/json: schema: type: object required: [name, unit_type, vat_code] properties: name: { type: string } code: { type: [string, 'null'] } unit_type: type: string description: | Magazzino: `kg`, `L`, `kit`, `pz`. Manodopera: `gg`, `h`, `lav`, `min`. stock: { type: [number, 'null'] } minimum_stock: { type: [number, 'null'] } purchase_price: { type: [number, 'null'] } sale_price: { type: [number, 'null'] } vat_code: type: string description: Deve essere una delle aliquote attive dell'azienda. unit_discount_rate: { type: [integer, 'null'] } location: { type: [string, 'null'] } comments: { type: [string, 'null'] } supplier_id: { type: [string, 'null'] } example: name: Filtro olio code: FO-2231 unit_type: pz stock: 12 purchase_price: 6.4 sale_price: 14.9 vat_code: '22' supplier_id: null responses: '200': description: success x-description-it: "L'articolo aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: article id: f18c4b62-9d07-4e53-a294-6b1f8d3c5e70 code: FO-2231 name: Filtro olio kind: item unit_type: pz stock: 12 sale_price: 14.9 vat_code: '22' unit_discount_rate: 0 minimum_stock: 4 purchase_price: 6.4 location: Scaffale A2 comments: Compatibile con motori 1.2 FIRE supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '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 x-summary-it: "Movimenti di un articolo" x-scope: articles.read x-description-it: | Lo storico di carico e scarico dell'articolo. `quantity` è positiva per i carichi e negativa per gli scarichi; `source` indica il documento che ha generato il movimento e `source_id` lo identifica. 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 x-description-it: "I movimenti dell'articolo." 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' } example: object: list data: - object: article_movement id: 8b4d2f06-1c73-4a95-b028-5e7f3a1d9c64 quantity: -4 source: worksheet source_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 document_date: '2026-07-15' customer_name: Mario Rossi vehicle_name: Fiat Panda total: 74.0 created_at: '2026-07-15T08:31:00Z' - object: article_movement id: 3f7a9c25-6d81-4e40-9b53-2a8c1f4d7e06 quantity: 20 source: delivery_note source_id: 9d1e6b48-5c20-4f37-a814-3b7e2d9f5a61 seq_number: 77 document_date: '2026-07-02' customer_name: null vehicle_name: null total: 128.0 created_at: '2026-07-02T11:14:00Z' has_more: false url: /v1/articles/f18c4b62-9d07-4e53-a294-6b1f8d3c5e70/movements '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca gli appuntamenti" x-scope: calendar.read x-description-it: | Gli appuntamenti in agenda. Filtra per intervallo con `from` / `to`, per calendario con `calendar_id` e per collaboratore con `user_id`. Ogni voce include cliente, veicolo e calendario già risolti, senza chiamate aggiuntive. 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. x-description-it: "Gli appuntamenti nell'intervallo richiesto." 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`. example: object: list data: - object: calendar_entry id: c72e9a41-8b35-4d07-9e62-1f4a8c3d5b90 title: Tagliando Fiat Panda description: Cliente lascia l'auto alle 8:30. start_time: '2026-08-12T08:30:00Z' end_time: '2026-08-12T10:00:00Z' color: '#2563eb' calendar_id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 user_id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 quote_id: null checkin_id: null work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando email_reminder: false auto_reminder_enabled: false auto_reminder_channel: null auto_reminder_sent_at: null customer: object: customer id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd full_name: Mario Rossi vehicle: object: vehicle id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 model: Panda license: AB123CD calendar: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 user: object: user id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 name: Luca Bianchi deleted_at: null created_at: '2026-08-01T09:00:00Z' updated_at: '2026-08-01T09:00:00Z' has_more: false url: /v1/calendar-entries '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Calendar] summary: Create a calendar entry x-summary-it: "Crea un appuntamento" x-scope: calendar.write x-description-it: | Fissa un appuntamento. Servono `start_time` e `end_time` in RFC 3339; collega il lavoro con `customer_id`, `vehicle_id`, `worksheet_id` o `quote_id`. I promemoria automatici via SMS o WhatsApp non sono disponibili via API: inviano messaggi ai tuoi clienti e consumano crediti, quindi una richiesta con `auto_reminder_enabled: true` e un canale a pagamento viene rifiutata con `403`. Il canale `email` resta ammesso perché usa il tuo mittente e non costa crediti. 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. x-description-it: "L'appuntamento creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CalendarEntry' } example: object: calendar_entry id: c72e9a41-8b35-4d07-9e62-1f4a8c3d5b90 title: Tagliando Fiat Panda description: Cliente lascia l'auto alle 8:30. start_time: '2026-08-12T08:30:00Z' end_time: '2026-08-12T10:00:00Z' color: '#2563eb' calendar_id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 user_id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 quote_id: null checkin_id: null work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando email_reminder: false auto_reminder_enabled: false auto_reminder_channel: null auto_reminder_sent_at: null customer: object: customer id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd full_name: Mario Rossi vehicle: object: vehicle id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 model: Panda license: AB123CD calendar: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 user: object: user id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 name: Luca Bianchi deleted_at: null created_at: '2026-08-01T09:00:00Z' updated_at: '2026-08-01T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un appuntamento" x-scope: calendar.read x-description-it: | Restituisce il singolo appuntamento con cliente, veicolo, calendario e collaboratore già risolti. 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. x-description-it: "L'appuntamento richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CalendarEntry' } example: object: calendar_entry id: c72e9a41-8b35-4d07-9e62-1f4a8c3d5b90 title: Tagliando Fiat Panda description: Cliente lascia l'auto alle 8:30. start_time: '2026-08-12T08:30:00Z' end_time: '2026-08-12T10:00:00Z' color: '#2563eb' calendar_id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 user_id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 quote_id: null checkin_id: null work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando email_reminder: false auto_reminder_enabled: false auto_reminder_channel: null auto_reminder_sent_at: null customer: object: customer id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd full_name: Mario Rossi vehicle: object: vehicle id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 model: Panda license: AB123CD calendar: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 user: object: user id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 name: Luca Bianchi deleted_at: null created_at: '2026-08-01T09:00:00Z' updated_at: '2026-08-01T09:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Calendar] summary: Update a calendar entry x-summary-it: "Aggiorna un appuntamento" x-scope: calendar.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati. Valgono gli stessi limiti sui promemoria a pagamento della creazione. In più, un appuntamento che ha già un promemoria automatico attivo non è modificabile via API: la modifica potrebbe far partire un nuovo messaggio al cliente, quindi la richiesta risponde `403` e va fatta dall'app. 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. x-description-it: "L'appuntamento aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CalendarEntry' } example: object: calendar_entry id: c72e9a41-8b35-4d07-9e62-1f4a8c3d5b90 title: Tagliando Fiat Panda description: Cliente lascia l'auto alle 8:30. start_time: '2026-08-12T08:30:00Z' end_time: '2026-08-12T10:00:00Z' color: '#2563eb' calendar_id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 user_id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 quote_id: null checkin_id: null work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando email_reminder: false auto_reminder_enabled: false auto_reminder_channel: null auto_reminder_sent_at: null customer: object: customer id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd full_name: Mario Rossi vehicle: object: vehicle id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 model: Panda license: AB123CD calendar: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 user: object: user id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 name: Luca Bianchi deleted_at: null created_at: '2026-08-01T09:00:00Z' updated_at: '2026-08-01T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un appuntamento" x-scope: calendar.write x-description-it: | Eliminazione logica dell'appuntamento. 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. x-description-it: "L'appuntamento eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CalendarEntry' } example: object: calendar_entry id: c72e9a41-8b35-4d07-9e62-1f4a8c3d5b90 title: Tagliando Fiat Panda description: Cliente lascia l'auto alle 8:30. start_time: '2026-08-12T08:30:00Z' end_time: '2026-08-12T10:00:00Z' color: '#2563eb' calendar_id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 user_id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 quote_id: null checkin_id: null work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando email_reminder: false auto_reminder_enabled: false auto_reminder_channel: null auto_reminder_sent_at: null customer: object: customer id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd full_name: Mario Rossi vehicle: object: vehicle id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 model: Panda license: AB123CD calendar: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 user: object: user id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 name: Luca Bianchi deleted_at: null created_at: '2026-08-01T09:00:00Z' updated_at: '2026-08-01T09:00:00Z' deleted_at: '2026-08-05T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/calendars: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Calendar] summary: List calendars x-summary-it: "Elenca i calendari" x-scope: calendar.read x-description-it: | I calendari configurati dall'officina, da usare per valorizzare `calendar_id`. L'elenco è breve e non paginato. 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. x-description-it: "I calendari configurati." 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' } example: object: list data: - object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 default_color: '#2563eb' - object: calendar id: 6d2b9f14-7c58-4a03-b91e-3f5a8d2c6e47 name: Carrozzeria default_color: '#16a34a' has_more: false url: /v1/calendars '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Calendar] summary: Create a calendar x-summary-it: "Crea un calendario" x-scope: calendar.write x-description-it: | Crea un calendario. `default_color` è il colore esadecimale usato in agenda per le voci che non ne specificano uno. operationId: calendarCreate requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } default_color: type: string description: Colore esadecimale usato in agenda, es. `#2563eb`. example: name: Officina 1 default_color: '#2563eb' responses: '201': description: success x-description-it: "Il calendario creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 default_color: '#2563eb' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un calendario" x-scope: calendar.write x-description-it: | Elimina il calendario. Gli appuntamenti che vi appartenevano vengono riassegnati a un altro calendario. operationId: calendarDelete responses: '200': description: success x-description-it: "Il calendario eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Calendar] summary: Update calendar x-summary-it: "Aggiorna un calendario" x-scope: calendar.write x-description-it: | Aggiorna nome e colore predefinito del calendario. operationId: calendarPatch requestBody: required: true description: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. content: application/json: schema: type: object required: [name] properties: name: { type: string } default_color: type: string description: Colore esadecimale usato in agenda, es. `#2563eb`. example: name: Officina 1 default_color: '#2563eb' responses: '200': description: success x-description-it: "Il calendario aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: calendar id: a18f3c65-2d90-4b47-8e13-6c9b2f5a7d04 name: Officina 1 default_color: '#2563eb' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/cashbook-entries: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [CashBook] summary: List cashbook entries x-summary-it: "Elenca i movimenti di cassa" x-scope: cashbook.read x-description-it: | I movimenti di prima nota, incassi e pagamenti insieme. Filtra per conto con `financial_account_id` e per periodo con `from` / `to`. Ogni riga porta `running_balance`, il saldo progressivo dopo quel movimento, e i riferimenti al documento che lo ha generato (fattura, scontrino, scheda lavoro o spesa). 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. x-description-it: "I movimenti di prima nota." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CashBookListEnvelope' } example: object: list data: - object: cashbook_entry id: 2f8b6d31-7a05-4c94-8e26-1d5f3a9c7b40 payment_type: incasso amount: 285.36 payment_date: '2026-07-17' financial_account_id: d61c8b04-3f97-4a25-8e70-9b2d5f1a6c38 financial_account_name: Cassa customer_name: Mario Rossi supplier_name: null invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 invoice_seq_number: 214 invoice_issued_at: '2026-07-16T10:30:00Z' running_balance: 4821.9 has_more: false url: /v1/cashbook-entries date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca le accettazioni" x-scope: checkins.read x-description-it: | Le accettazioni dell'officina, dalla più recente. Filtra per stato con `status` (`active` o `completed`), per testo con `q` e per periodo con `from` / `to`. Negli elenchi `line_items` è `null` per alleggerire la risposta: le righe arrivano solo sul dettaglio. 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 } - name: direction in: query required: false description: "Direzione di ordinamento: `asc` o `desc`." schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: sort in: query required: false description: Colonna di ordinamento. schema: { type: string } responses: '200': description: Page of checkins. x-description-it: "Una pagina di accettazioni." 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' } example: object: list data: - object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/checkins date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Checkins] summary: Create a checkin x-summary-it: "Crea un'accettazione" x-scope: checkins.write x-description-it: | Registra l'accettazione di un veicolo. Il numero progressivo (`seq_number`) è assegnato dal server e resta stabile. I campi da `section1` a `section8` sono caselle di testo libero usate per le note di accettazione; l'API non impone loro alcun significato. `fuel_level` è un intero, per convenzione 0-100, senza vincoli. 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. x-description-it: "L'accettazione creata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Conta le accettazioni per stato" x-scope: checkins.read x-description-it: | Quante accettazioni sono `active` e quante `completed`, con gli stessi filtri dell'elenco. Serve a popolare i contatori senza scaricare le accettazioni. 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` parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di accettazioni per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CheckinStatusCounts' } example: object: checkin_status_counts active: 4 completed: 87 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un'accettazione" x-scope: checkins.read x-description-it: | Restituisce l'accettazione con le righe (`line_items`). Se è già stata convertita, `worksheet_id` e `quote_id` puntano ai documenti generati. 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. x-description-it: "L'accettazione richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' comments: Cliente segnala rumore in frenata. section1: Carrozzeria senza danni evidenti. section2: Pneumatici anteriori da sostituire. line_items: - object: checkin_line_item id: 5c1e8b73-2a94-4d06-b8f5-3e7a1d4c9601 description: Controllare pastiglie anteriori created_at: '2026-07-15T07:52:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Checkins] summary: Update a checkin x-summary-it: "Aggiorna un'accettazione" x-scope: checkins.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati, e `null` svuota il campo. Lo stato non si cambia da qui: usa `POST /v1/checkins/{id}/transitions`. 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. x-description-it: "L'accettazione aggiornata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' comments: Cliente segnala rumore in frenata. section1: Carrozzeria senza danni evidenti. section2: Pneumatici anteriori da sostituire. line_items: - object: checkin_line_item id: 5c1e8b73-2a94-4d06-b8f5-3e7a1d4c9601 description: Controllare pastiglie anteriori created_at: '2026-07-15T07:52:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un'accettazione" x-scope: checkins.write x-description-it: | Eliminazione logica: l'accettazione sparisce dagli elenchi. I documenti eventualmente generati da essa restano intatti. description: | Stamps `deleted_at`; subsequent GET/PATCH/DELETE all 404. Returns the deleted resource (200) for consistency. responses: '200': description: Checkin soft-deleted. x-description-it: "L'accettazione eliminata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Converti in scheda lavoro" x-scope: checkins.write x-description-it: | Genera una scheda lavoro dall'accettazione e collega i due documenti (`worksheet_id` sull'accettazione). Attenzione: la risposta è la **scheda lavoro** creata, non l'accettazione — l'oggetto restituito ha `object: worksheet`. 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). x-description-it: "La scheda lavoro generata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: draft title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 total_after_tax: 0 frozen: false created_at: '2026-07-15T08:12:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Allega un documento all'accettazione" x-scope: checkins.write x-description-it: | Carica un file fra i documenti dell'accettazione. La richiesta è `multipart/form-data` con il file nel campo `file`, non JSON. operationId: checkinDocumentCreate description: | multipart/form-data: file required — the document bytes (max 25MB) description optional — display label; defaults to the file name 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: success x-description-it: "Il documento allegato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CheckinDocument' } example: object: checkin_document id: 7f2c6a18-9e40-4b53-8d21-4a6f1c9e3b75 name: foto-danno.jpg content_type: image/jpeg description: Graffio parafango file_size_bytes: 233104 url: /v1/checkins/e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60/documents/7f2c6a18-9e40-4b53-8d21-4a6f1c9e3b75/download created_at: '2026-07-15T07:58:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un documento" x-scope: checkins.write x-description-it: | Rimuove definitivamente il file allegato. operationId: checkinDocumentDelete description: | CheckinDocumentDelete handles DELETE /v1/checkins/{id}/documents/{documentID}. responses: '200': description: success (the removed document) x-description-it: "Il documento eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CheckinDocument' } example: object: checkin_document id: 7f2c6a18-9e40-4b53-8d21-4a6f1c9e3b75 name: foto-danno.jpg content_type: image/jpeg description: Graffio parafango file_size_bytes: 233104 url: /v1/checkins/e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60/documents/7f2c6a18-9e40-4b53-8d21-4a6f1c9e3b75/download created_at: '2026-07-15T07:58:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Scarica un documento" x-scope: checkins.read x-description-it: | Restituisce il contenuto del file, non JSON. È il percorso indicato dal campo `url` di ogni documento. 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Modifica le righe in blocco" x-scope: checkins.write x-description-it: | Aggiunge, aggiorna, rimuove e riordina le righe in una sola richiesta, applicata come singola transazione. Le righe di un'accettazione sono sole annotazioni: portano una `description` e nessun prezzo. 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`). x-description-it: "L'accettazione con le righe aggiornate." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: active title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' comments: Cliente segnala rumore in frenata. section1: Carrozzeria senza danni evidenti. section2: Pneumatici anteriori da sostituire. line_items: - object: checkin_line_item id: 5c1e8b73-2a94-4d06-b8f5-3e7a1d4c9601 description: Controllare pastiglie anteriori created_at: '2026-07-15T07:52:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Crea un preventivo dall'accettazione" x-scope: checkins.write x-description-it: | Genera un preventivo dall'accettazione e collega i due documenti (`quote_id` sull'accettazione). Attenzione: la risposta è il **preventivo** creato, non l'accettazione — l'oggetto restituito ha `object: quote`. 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). x-description-it: "Il preventivo generato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 total_after_tax: 0 created_at: '2026-07-15T08:15:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Cambia stato all'accettazione" x-scope: checkins.write x-description-it: | Porta l'accettazione in `active` o `completed`. È l'unico modo di cambiare `status`: un valore diverso da questi due viene rifiutato con `422`. 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. x-description-it: "L'accettazione dopo la transizione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Checkin' } example: object: checkin id: e4b7d290-6c15-4a83-9f27-1d8b3e5a7c60 seq_number: 142 status: completed title: Accettazione tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 fuel_level: 45 worksheet_id: null quote_id: null created_at: '2026-07-15T07:40:00Z' updated_at: '2026-07-15T07:55:00Z' comments: Cliente segnala rumore in frenata. section1: Carrozzeria senza danni evidenti. section2: Pneumatici anteriori da sostituire. line_items: - object: checkin_line_item id: 5c1e8b73-2a94-4d06-b8f5-3e7a1d4c9601 description: Controllare pastiglie anteriori created_at: '2026-07-15T07:52:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera i dati azienda" x-scope: settings.read x-description-it: | L'anagrafica dell'officina e le preferenze che governano i documenti: metodo di pagamento predefinito, colore, IBAN, se i prezzi sono IVA inclusa. `fiscal_fields_locked` indica che partita IVA e codice fiscale non sono più modificabili perché sono già stati usati su un documento fiscale. operationId: companyGet responses: '200': description: success x-description-it: "I dati dell'azienda." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: company_profile id: 1f8c4e73-2a95-4b06-9d31-7e5a2c8b6f40 name: Officina Rossi S.r.l. entity_type: societa company_type: officina tax_id: '01234567890' fiscal_code: 'RSSMRA80A01F205X' email: info@officinarossi.example.it phone: '+390212345678' address: Via Mecenate 12 city: Milano zip_code: '20138' province: MI fiscal_fields_locked: true primary_color: '#2563eb' default_payment_method: MP05 bank_account_holder: Officina Rossi S.r.l. line_items_including_vat: false worksheet_km_required: true '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/credit-notes: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [CreditNotes] summary: List credit notes x-summary-it: "Elenca le note di credito" x-scope: invoices.read x-description-it: | Le note di credito emesse, dalla più recente. Stessa paginazione a cursore dell'elenco fatture. 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 } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Page of credit notes. x-description-it: "Una pagina di note di credito." 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' } example: object: list data: - object: credit_note id: 9e5a3d78-1b46-4c02-8f93-7a2d5e1c8b40 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 seq_number: 12 title: Storno parziale tagliando sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sdi_status: delivered total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-18T09:00:00Z' updated_at: '2026-07-18T09:00:00Z' has_more: false url: /v1/credit-notes '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Crea una nota di credito" x-scope: invoices.write x-description-it: | Storna una fattura emessa: passa `invoice_id` e righe e totali vengono ripresi dalla fattura di origine. È il modo corretto di annullare una fattura già emessa, che non è eliminabile. 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. x-description-it: "La nota di credito creata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CreditNote' } example: object: credit_note id: 9e5a3d78-1b46-4c02-8f93-7a2d5e1c8b40 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 seq_number: 12 title: Storno parziale tagliando sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sdi_status: delivered total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-18T09:00:00Z' updated_at: '2026-07-18T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera una nota di credito" x-scope: invoices.read x-description-it: | Restituisce la nota di credito e il riferimento alla fattura stornata (`invoice_id`). 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. x-description-it: "La nota di credito richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CreditNote' } example: object: credit_note id: 9e5a3d78-1b46-4c02-8f93-7a2d5e1c8b40 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 seq_number: 12 title: Storno parziale tagliando sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sdi_status: delivered total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-18T09:00:00Z' updated_at: '2026-07-18T09:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Aggiorna una nota di credito" x-scope: invoices.write x-description-it: | Aggiorna i campi descrittivi. Gli importi derivano dalla fattura di origine e non si modificano da qui. 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. x-description-it: "La nota di credito aggiornata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CreditNote' } example: object: credit_note id: 9e5a3d78-1b46-4c02-8f93-7a2d5e1c8b40 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 seq_number: 12 title: Storno parziale tagliando sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sdi_status: delivered total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-18T09:00:00Z' updated_at: '2026-07-18T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina una nota di credito" x-scope: invoices.write x-description-it: | Eliminazione logica della nota di credito. Una nota di credito la cui controparte elettronica è già stata trasmessa allo SdI non è eliminabile: la richiesta risponde `409`. Il documento è ormai un record fiscale e va gestito dal gestionale web. 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. x-description-it: "La nota di credito eliminata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/CreditNote' } example: object: credit_note id: 9e5a3d78-1b46-4c02-8f93-7a2d5e1c8b40 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 seq_number: 12 title: Storno parziale tagliando sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sdi_status: delivered total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-18T09:00:00Z' updated_at: '2026-07-18T09:00:00Z' deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.read 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Customers] summary: Create a customer x-scope: customers.write 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.read 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Customers] summary: Update a customer x-scope: customers.write 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: null city: Milano created_at: '2026-08-03T14:32:11Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.write 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.write 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 name: libretto.pdf content_type: application/pdf file_size_bytes: 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.write 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 name: libretto.pdf content_type: application/pdf file_size_bytes: 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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-scope: customers.read 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un widget concessionaria" x-scope: reports.read x-description-it: | Come `GET /v1/report/{widget}`, ma per le statistiche della concessionaria. Stessa busta di risposta. 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). parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "I dati del widget concessionaria." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: report_widget widget: stock_aging preset: year from: '2026-01-01' to: '2026-12-31' data: - label: 0-30 giorni value: 4 - label: 31-90 giorni value: 7 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/delivery-notes: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Suppliers] summary: List delivery notes x-summary-it: "Elenca i documenti di trasporto" x-scope: suppliers.read x-description-it: | I DDT ricevuti dai fornitori, in sola lettura. 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 } - name: direction in: query required: false description: "Direzione di ordinamento: `asc` o `desc`." schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: sort in: query required: false description: Colonna di ordinamento. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: + date_filter success x-description-it: "I documenti di trasporto." 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' } example: object: list data: - object: delivery_note id: 9d1e6b48-5c20-4f37-a814-3b7e2d9f5a61 seq_number: 77 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. description: Consegna filtri e olio status: issued delivery_date: '2026-07-02' total_before_tax: 128.0 total_tax: 28.16 total_after_tax: 156.16 created_at: '2026-07-02T11:14:00Z' has_more: false url: /v1/delivery-notes date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '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 x-summary-it: "Conta i DDT per stato" x-scope: suppliers.read x-description-it: | Il numero di documenti di trasporto in ciascuno stato, con gli stessi filtri dell'elenco. 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. parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di DDT per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/DeliveryNoteStatusCounts' } example: object: delivery_note_status_counts draft: 1 issued: 64 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '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 x-summary-it: "Elenca le fatture elettroniche emesse" x-scope: invoices.read x-description-it: | Le fatture elettroniche trasmesse allo SdI, con il loro stato (`sent`, `delivered`, `rejected`). L'intero gruppo è in sola lettura: la trasmissione allo SdI non è esposta su questa API. Rientra nel permesso `invoices`, non ne ha uno proprio. 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). parameters: - name: document_type in: query required: false description: Filtra per tipo documento (es. `TD01`). schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Le fatture elettroniche emesse." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: electronic_invoice id: 6b2f9d54-8c17-4a03-be91-2d7c5a4f8e30 status: delivered document_type: TD01 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 credit_note_id: null receipt_number: null seq_number: 214 customer_name: Mario Rossi vehicle_name: Fiat Panda total_after_discount: 285.36 date: '2026-07-16' sent_at: '2026-07-16T10:35:00Z' delivered_at: '2026-07-16T11:02:00Z' rejected_at: null created_at: '2026-07-16T10:35:00Z' has_more: false url: /v1/electronic-invoices date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/electronic-invoices-incoming: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [ElectronicInvoicing] summary: List electronic invoice incomings x-summary-it: "Elenca le fatture elettroniche ricevute" x-scope: invoices.read x-description-it: | Le fatture elettroniche ricevute dai fornitori. `expense_id` è valorizzato quando la fattura è già stata registrata come spesa. operationId: electronicInvoiceIncomingList description: | — the passive supplier invoices synced from ACube. FatturaPA code, ""=all), supplier_id, from/to/preset (sticky per view). parameters: - name: document_type in: query required: false description: Filtra per tipo documento (es. `TD01`). schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Le fatture elettroniche ricevute." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: electronic_invoice_incoming id: 4c7e1b90-6d23-4a85-9f31-7b2c8e5d4a06 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 business_name: Ricambi Lombardia S.r.l. invoice_number: '2026/1184' invoice_date: '2026-07-28' total_amount: 512.4 status: delivered document_type: TD01 expense_id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 created_at: '2026-07-29T06:00:00Z' has_more: false url: /v1/electronic-invoices-incoming date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '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 x-summary-it: "Conta le fatture ricevute per stato" x-scope: invoices.read x-description-it: | Il numero di fatture ricevute in ciascuno stato, con gli stessi filtri dell'elenco. operationId: electronicInvoiceIncomingStatusCounts description: | /v1/electronic-invoices-incoming/status-counts — the Passive tab counters (Ricevute / In quarantena), same filters as the list minus status. parameters: - name: document_type in: query required: false description: Filtra per tipo documento (es. `TD01`). schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di fatture ricevute per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: electronic_invoice_incoming_status_counts received: 214 quarantena: 0 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/electronic-invoices/status-counts: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [ElectronicInvoicing] summary: Electronic invoice status counts x-summary-it: "Conta le fatture elettroniche per stato" x-scope: invoices.read x-description-it: | Il numero di fatture elettroniche in ciascuno stato, con gli stessi filtri dell'elenco. 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). parameters: - name: document_type in: query required: false description: Filtra per tipo documento (es. `TD01`). schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di fatture elettroniche per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: electronic_invoice_status_counts sent: 3 delivered: 128 rejected: 1 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/expense-categories: parameters: [{ $ref: '#/components/parameters/APIVersion' }] get: tags: [Expenses] summary: List expense categories (unpaginated) x-summary-it: "Elenca le categorie di spesa" x-scope: expenses.read x-description-it: | Le categorie configurate dall'officina, da usare per valorizzare `expense_category_id`. responses: '200': description: 'Categories.' x-description-it: "Le categorie di spesa." content: application/json: example: object: list data: - object: expense_category id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 name: Ricambi - object: expense_category id: 5d2c9b78-4e13-40fa-8265-3b7a1f6d9c04 name: Utenze has_more: false url: /v1/expense-categories '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/expenses: parameters: [{ $ref: '#/components/parameters/APIVersion' }] get: tags: [Expenses] summary: List expenses x-summary-it: "Elenca le spese" x-scope: expenses.read x-description-it: | Le spese registrate. Filtra per stato con `status`, per fornitore con `supplier_id` e per periodo con `from` / `to`. parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: 'Expenses + date_filter.' x-description-it: "Le spese registrate." content: application/json: example: object: list data: - object: expense id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 seq_number: 91 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. expense_category_id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 category_name: Ricambi description: Ricambi vari luglio invoice_number: '2026/1184' comments: null status: to_pay expired: false payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 total_discount: 0 total_payments: 0 outstanding_payments: 512.4 is_stornata: false created_at: '2026-07-28T09:15:00Z' updated_at: '2026-07-28T09:15:00Z' has_more: false url: /v1/expenses date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Expenses] summary: Create an expense (totals-first) x-summary-it: "Registra una spesa" x-scope: expenses.write x-description-it: | Registra una spesa verso un fornitore. Gli importi si passano al netto (`total_before_tax`) e come imposta (`total_tax`): il totale lordo è calcolato dal server. requestBody: required: true content: application/json: schema: type: object required: [supplier_id, total_before_tax, total_tax] 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'] } example: supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 description: Ricambi vari luglio invoice_number: '2026/1184' payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 responses: '201': description: 'Expense.' x-description-it: "La spesa registrata." content: application/json: example: object: expense id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 seq_number: 91 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. expense_category_id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 category_name: Ricambi description: Ricambi vari luglio invoice_number: '2026/1184' comments: null status: to_pay expired: false payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 total_discount: 0 total_payments: 0 outstanding_payments: 512.4 is_stornata: false created_at: '2026-07-28T09:15:00Z' updated_at: '2026-07-28T09:15:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Conta le spese per stato" x-scope: expenses.read x-description-it: | Il numero di spese in ciascuno stato, con gli stessi filtri dell'elenco. parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: supplier_id in: query required: false description: Filtra per fornitore. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: 'Counts + date_filter.' x-description-it: "Il numero di spese per stato." content: application/json: example: object: expense_status_counts to_pay: 6 expired: 2 paid: 143 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Recupera una spesa" x-scope: expenses.read x-description-it: | Restituisce la spesa con le righe, le rate, i pagamenti e il residuo da pagare (`outstanding_payments`). responses: '200': description: 'Expense.' x-description-it: "La spesa richiesta." content: application/json: example: object: expense id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 seq_number: 91 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. expense_category_id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 category_name: Ricambi description: Ricambi vari luglio invoice_number: '2026/1184' comments: null status: to_pay expired: false payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 total_discount: 0 total_payments: 0 outstanding_payments: 512.4 is_stornata: false created_at: '2026-07-28T09:15:00Z' updated_at: '2026-07-28T09:15:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Expenses] summary: Update header fields and totals x-summary-it: "Aggiorna una spesa" x-scope: expenses.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati. requestBody: required: true content: application/json: schema: type: object properties: 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'] } example: description: Ricambi vari luglio (rettificato) total_before_tax: 400.0 total_tax: 88.0 responses: '200': description: 'Updated expense.' x-description-it: "La spesa aggiornata." content: application/json: example: object: expense id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 seq_number: 91 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. expense_category_id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 category_name: Ricambi description: Ricambi vari luglio invoice_number: '2026/1184' comments: null status: to_pay expired: false payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 total_discount: 0 total_payments: 0 outstanding_payments: 512.4 is_stornata: false created_at: '2026-07-28T09:15:00Z' updated_at: '2026-07-28T09:15:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } delete: tags: [Expenses] summary: Delete an expense x-summary-it: "Elimina una spesa" x-scope: expenses.write x-description-it: | Eliminazione logica della spesa. responses: '200': description: 'The removed expense.' x-description-it: "La spesa eliminata." content: application/json: example: object: expense id: ba38f519-4c72-4e06-9d81-5a2c7f4b3e90 seq_number: 91 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. expense_category_id: 1a6f8d47-3c92-4e05-b731-8d2b5a9c4e60 category_name: Ricambi description: Ricambi vari luglio invoice_number: '2026/1184' comments: null status: to_pay expired: false payment_method: MP05 payment_due_at: '2026-08-31T00:00:00Z' total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 total_discount: 0 total_payments: 0 outstanding_payments: 512.4 is_stornata: false created_at: '2026-07-28T09:15:00Z' updated_at: '2026-07-28T09:15:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca i pagamenti della spesa" x-scope: expenses.read x-description-it: | I pagamenti registrati sulla spesa. Il residuo è `outstanding_payments` sulla spesa stessa. operationId: expensePaymentList responses: '200': description: success x-description-it: "I pagamenti della spesa." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: expense_payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 512.4 payment_method: MP05 payment_date: '2026-08-01' financial_account_id: d61c8b04-3f97-4a25-8e70-9b2d5f1a6c38 created_at: '2026-08-01T10:00:00Z' has_more: false url: /v1/expenses/ba38f519-4c72-4e06-9d81-5a2c7f4b3e90/payments '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Expenses] summary: Create an expense payment x-summary-it: "Registra un pagamento" x-scope: expenses.write x-description-it: | Registra un pagamento sulla spesa. `payment_date` è nel formato `YYYY-MM-DD` e vale la data odierna se omesso. L'importo può essere parziale. A differenza di ogni altro endpoint, la risposta è un `201` **senza corpo**: il pagamento non viene restituito. Rileggi la spesa per vedere i totali aggiornati. operationId: expensePaymentCreate requestBody: required: true content: application/json: schema: type: object required: [amount] properties: amount: { type: number } payment_method: { type: [string, 'null'] } payment_date: type: [string, 'null'] description: Formato `YYYY-MM-DD`. Se omesso vale la data odierna. financial_account_id: { type: [string, 'null'] } example: amount: 512.4 payment_method: MP05 payment_date: '2026-08-01' responses: '201': description: | Created. Unlike every other endpoint on this API this one answers with an EMPTY body — the payment is not echoed back. Re-read the expense to see the updated totals. x-description-it: "Pagamento registrato. Risposta senza corpo." '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /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 x-summary-it: "Elimina un pagamento" x-scope: expenses.write x-description-it: | Rimuove il pagamento e riporta il residuo della spesa al valore precedente. operationId: expensePaymentDelete responses: '204': description: | Deleted. No body — unlike the invoice and worksheet payment deletes, which echo the removed payment. x-description-it: "Pagamento eliminato. Risposta senza corpo." '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/financial-accounts: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [CashBook] summary: List the company's financial accounts x-summary-it: "Elenca i conti" x-scope: cashbook.read x-description-it: | I conti configurati (cassa, banca), da usare per valorizzare `financial_account_id` sui pagamenti. 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. x-description-it: "I conti configurati." 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 } example: object: list data: - object: financial_account id: d61c8b04-3f97-4a25-8e70-9b2d5f1a6c38 type: cassa name: Cassa - object: financial_account id: 7e3c9a25-1b68-4f40-9d72-5a8e2c6b3f19 type: banca name: Banca Intesa has_more: false url: /v1/financial-accounts '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } # ────────────── Health ────────────── post: tags: [CashBook] summary: Create a financial account x-summary-it: "Crea un conto" x-scope: cashbook.write x-description-it: | Crea un conto finanziario. `type` distingue cassa e banca. operationId: financialAccountCreate requestBody: required: true content: application/json: schema: type: object required: [name, type] properties: name: { type: string } type: type: string description: Tipo di conto, es. `cassa` o `banca`. example: name: Banca Intesa type: banca responses: '201': description: success x-description-it: "Il conto creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: financial_account name: Banca Intesa type: banca '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '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 x-summary-it: "Elimina un conto" x-scope: cashbook.write x-description-it: | Elimina il conto. I movimenti già registrati su di esso restano intatti. operationId: financialAccountDelete responses: '200': description: success x-description-it: "Il conto eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: financial_account id: 7e3c9a25-1b68-4f40-9d72-5a8e2c6b3f19 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [CashBook] summary: Update financial account x-summary-it: "Aggiorna un conto" x-scope: cashbook.write x-description-it: | Aggiorna il nome del conto. operationId: financialAccountPatch description: | (rename only — the type is fixed at creation, web parity). requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } example: name: Nuovo nome responses: '200': description: success x-description-it: "Il conto aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: financial_account id: 7e3c9a25-1b68-4f40-9d72-5a8e2c6b3f19 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/invoices: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Invoices] summary: List invoices x-summary-it: "Elenca le fatture" x-scope: invoices.read x-description-it: | Le fatture dell'officina, dalla più recente. Filtra per stato con `status` (`draft` o `issued`), per cliente con `customer_id` e per periodo con `from` / `to`. La paginazione usa un cursore opaco: passa `next_starting_after` invariato in `starting_after`. 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 } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: without_electronic_invoice in: query required: false description: Solo le fatture senza controparte elettronica. schema: { type: boolean } responses: '200': description: Page of invoices (line_items omitted). x-description-it: "Una pagina di fatture." 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' } example: object: list data: - object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: issued seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/invoices date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Crea una fattura" x-scope: invoices.write x-description-it: | La fattura nasce sempre da una scheda lavoro: passa `worksheet_id` e righe, totali e anagrafiche vengono copiati da lì. Non esiste un flusso «fattura vuota + aggiungi righe» su questa API. La fattura creata è in stato `draft` e non ha ancora numero: il numero viene assegnato da `POST /v1/invoices/{id}/issue`. 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: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 payment_method: bank_transfer payment_due_at: '2026-06-30T00:00:00Z' responses: '201': description: Invoice created. x-description-it: "La fattura creata, in bozza." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } example: object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: draft seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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/invoices/payment-counts: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Invoices] summary: Count invoices per payment state x-summary-it: "Conta le fatture per stato di incasso" x-scope: invoices.read x-description-it: | Quante fatture risultano da incassare (`pending`) e quante saldate (`settled`), con gli stessi filtri dell'elenco. 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' - name: fiscal_status in: query required: false description: Filtra per stato fiscale del documento. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Counts per payment state. x-description-it: "Il numero di fatture per stato di incasso." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/InvoicePaymentCounts' } example: object: invoice_payment_counts pending: 7 settled: 132 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera una fattura" x-scope: invoices.read x-description-it: | Restituisce la fattura con le righe (`line_items`) e i totali. Il campo `sdi_status` riporta lo stato della trasmissione elettronica in sola lettura: l'invio allo SdI non è esposto su questa API. 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. x-description-it: "La fattura richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } example: object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: issued seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' line_items: - object: invoice_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Invoices] summary: Update an invoice x-summary-it: "Aggiorna una fattura" x-scope: invoices.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati, e `null` svuota il campo. Una fattura già emessa accetta solo modifiche che non incidono sui valori fiscali. 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. x-description-it: "La fattura aggiornata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } example: object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: issued seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' line_items: - object: invoice_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina una fattura" x-scope: invoices.write x-description-it: | Eliminazione logica della fattura. Una fattura la cui controparte elettronica è già stata trasmessa allo SdI non è eliminabile: la richiesta risponde `409`. In quel caso il documento va stornato con una nota di credito (`POST /v1/credit-notes`), non eliminato. 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. x-description-it: "La fattura eliminata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } example: object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: issued seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '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 x-summary-it: "Converti in scontrino" x-scope: invoices.write x-description-it: | Trasforma la fattura in uno scontrino, mantenendo righe e totali. Attenzione: la risposta è lo **scontrino** creato, non la fattura — l'oggetto restituito ha `object: receipt`. 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). x-description-it: "Lo scontrino generato dalla fattura." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Receipt' } example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 seq_number: 58 status: draft worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 created_at: '2026-07-17T08:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Emetti una fattura" x-scope: invoices.write x-description-it: | Porta la fattura da `draft` a `issued`, assegnandole il numero progressivo del sezionale e valorizzando `issued_at`. È il punto in cui la fattura diventa un documento fiscale: da qui in poi non è più eliminabile. L'emissione non trasmette allo SdI — la trasmissione non è esposta su questa API. 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". x-description-it: "La fattura emessa, con numero e data." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } example: object: invoice id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 status: issued seq_number: 214 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: A payment_method: bonifico payment_due_at: '2026-08-15T00:00:00Z' total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 150.0 outstanding_payments: 135.36 sdi_status: delivered issued_at: '2026-07-16T10:30:00Z' created_at: '2026-07-16T10:12:00Z' updated_at: '2026-07-16T10:30:00Z' line_items: - object: invoice_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca i pagamenti della fattura" x-scope: invoices.read x-description-it: | Gli incassi registrati sulla fattura. Il residuo è `outstanding_payments` sulla fattura stessa. 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. x-description-it: "I pagamenti della fattura." 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 } example: object: list data: - object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP05 payment_date: '2026-07-16' created_at: '2026-07-16T10:30:00Z' has_more: false url: /v1/invoices/4412e7b9-0c35-4a18-92d7-5b6ea1f38c04/payments '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Invoices] summary: Register a payment on an invoice x-summary-it: "Registra un pagamento" x-scope: invoices.write x-description-it: | Registra un incasso sulla fattura. Qui `payment_method` è un codice ministeriale (`MP01` contanti, `MP05` bonifico, `MP08` carta), a differenza del campo omonimo sulla fattura che è un'etichetta libera. L'importo può essere parziale. 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. x-description-it: "Il pagamento registrato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Payment' } example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP05 payment_date: '2026-07-16' created_at: '2026-07-16T10:30:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un pagamento" x-scope: invoices.write x-description-it: | Rimuove l'incasso e riporta il residuo della fattura al valore precedente. 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. x-description-it: "Il pagamento eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Payment' } example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP05 payment_date: '2026-07-16' created_at: '2026-07-16T10:30:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/manufacturers: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Vehicles] summary: List vehicle manufacturers x-summary-it: "Elenca le marche attive" x-scope: vehicles.read x-description-it: | Le marche selezionate dall'officina, da usare per popolare `manufacturer_id` in fase di creazione o modifica di un veicolo. L'elenco è breve e non paginato: `has_more` è sempre `false`. 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. x-description-it: "Le marche attive dell'officina." 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 } example: object: list data: - object: manufacturer id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 name: Fiat - object: manufacturer id: 9a4c2e70-6b18-4d3f-a5c9-1e8b7d0f2a46 name: Volkswagen has_more: false url: /v1/manufacturers '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } put: tags: [Vehicles] summary: Replace manufacturer x-summary-it: "Imposta le marche attive" x-scope: settings.write x-description-it: | Sostituisce l'intero insieme delle marche attive dell'officina: le marche non elencate vengono disattivate. `new_names` crea al volo le marche non presenti a catalogo. Almeno una marca deve restare attiva. Richiede il permesso `settings.write`. operationId: manufacturerSet description: | company's enabled brand set; new_names creates custom brands (web "new-brand" parity) and enables them in the same call. requestBody: required: true content: application/json: schema: type: object required: [ids] properties: ids: type: array description: | The complete set of manufacturer IDs that stay active. Anything absent is deactivated. Must not be empty. items: { type: string } new_names: type: array description: | Manufacturer names not in the catalog, created on the fly and added to the active set. 2-60 characters each. items: { type: string } example: ids: - c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 - 9a4c2e70-6b18-4d3f-a5c9-1e8b7d0f2a46 new_names: - Ligier responses: '200': description: success x-description-it: "Le marche ora attive." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: manufacturers ids: - c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 - 9a4c2e70-6b18-4d3f-a5c9-1e8b7d0f2a46 '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/manufacturers/catalog: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Vehicles] summary: Manufacturer catalog x-summary-it: "Catalogo completo delle marche" x-scope: settings.read x-description-it: | Tutte le marche disponibili, con `selected` a indicare quelle attive per l'officina e `vehicle_count` a indicare quanti veicoli le usano. È la vista dietro Impostazioni -> Marche veicolo, quindi richiede il permesso `settings.read`. operationId: manufacturerCatalog description: | brand catalog with the company's enabled marks and usage counts. responses: '200': description: success x-description-it: "Il catalogo completo delle marche." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: manufacturer_catalog_entry id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 name: Fiat selected: true vehicle_count: 128 - object: manufacturer_catalog_entry id: 9a4c2e70-6b18-4d3f-a5c9-1e8b7d0f2a46 name: Volkswagen selected: true vehicle_count: 41 - object: manufacturer_catalog_entry id: 2f6d8b31-7c05-4a92-9e4d-3b1a6c8f5d20 name: Zastava selected: false vehicle_count: 0 has_more: false url: /v1/manufacturers/catalog '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/orders: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Suppliers] summary: List orders x-summary-it: "Elenca gli ordini a fornitore" x-scope: suppliers.read x-description-it: | Gli ordini emessi verso i fornitori, in sola lettura: creazione e modifica non sono esposte su questa API. 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 } - name: direction in: query required: false description: "Direzione di ordinamento: `asc` o `desc`." schema: { type: string } - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: sort in: query required: false description: Colonna di ordinamento. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: + date_filter success x-description-it: "Gli ordini a fornitore." 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' } example: object: list data: - object: order id: 8c14e7a2-5b30-4d96-a17f-2e9c6b4d8f05 seq_number: 214 supplier_id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 supplier_name: Ricambi Lombardia S.r.l. supplier_invoice_reference: '2026/1184' status: received total_before_tax: 420.0 total_tax: 92.4 total_after_tax: 512.4 payment_method: MP05 invoice_date: '2026-07-28' confirmed_at: '2026-07-20T09:00:00Z' received_at: '2026-07-27T11:30:00Z' created_at: '2026-07-18T15:00:00Z' has_more: false url: /v1/orders date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '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 x-summary-it: "Conta gli ordini per stato" x-scope: suppliers.read x-description-it: | Il numero di ordini in ciascuno stato, con gli stessi filtri dell'elenco. 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. parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di ordini per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/OrderStatusCounts' } example: object: order_status_counts pending: 2 delivery: 5 received: 118 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '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 x-summary-it: "Elenca i listini" x-scope: settings.read x-description-it: | I listini configurati, applicabili ai clienti per ottenere prezzi dedicati. operationId: priceListList responses: '200': description: success x-description-it: "I listini configurati." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: price_list id: 9c5e1a83-6b24-4d70-8f39-2a7c4e1b8d56 name: Listino flotte has_more: false url: /v1/price-lists '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Settings] summary: Create a price list x-summary-it: "Crea un listino" x-scope: settings.write x-description-it: | Crea un listino prezzi. operationId: priceListCreate requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } example: name: Nuovo nome responses: '201': description: success x-description-it: "Il listino creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: price_list id: 9c5e1a83-6b24-4d70-8f39-2a7c4e1b8d56 name: Listino flotte '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '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 x-summary-it: "Elimina un listino" x-scope: settings.write x-description-it: | Elimina il listino. I clienti che lo usavano tornano ai prezzi di listino base. operationId: priceListDelete responses: '200': description: success x-description-it: "Il listino eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: price_list id: 9c5e1a83-6b24-4d70-8f39-2a7c4e1b8d56 name: Listino flotte '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Settings] summary: Update price list x-summary-it: "Aggiorna un listino" x-scope: settings.write x-description-it: | Aggiorna il nome del listino. operationId: priceListPatch requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } example: name: Nuovo nome responses: '200': description: success x-description-it: "Il listino aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: price_list id: 9c5e1a83-6b24-4d70-8f39-2a7c4e1b8d56 name: Listino flotte '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/quotes: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Quotes] summary: List quotes x-summary-it: "Elenca i preventivi" x-scope: quotes.read x-description-it: | I preventivi dell'officina, dal più recente. Filtra per stato con `status`, per cliente con `customer_id`, per veicolo con `vehicle_id` e per periodo con `from` / `to`. La paginazione usa un cursore opaco: passa `next_starting_after` invariato in `starting_after` e fermati quando `has_more` è `false`. 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 } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Page of quotes (without line items). x-description-it: "Una pagina di preventivi." 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' } example: object: list data: - object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/quotes date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Quotes] summary: Create a quote x-summary-it: "Crea un preventivo" x-scope: quotes.write x-description-it: | Crea un preventivo in stato `waiting`. Il numero progressivo (`seq_number`) è assegnato dal server. Le righe non si passano qui: crea il preventivo, poi aggiungile con `POST /v1/quotes/{id}/line-items/batch`. 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. x-description-it: "Il preventivo creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Conta i preventivi per stato" x-scope: quotes.read x-description-it: | Il numero di preventivi in ciascuno stato, con gli stessi filtri dell'elenco. Serve a popolare i contatori senza scaricare i preventivi. 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' - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Counts per status. x-description-it: "Il numero di preventivi per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/QuoteStatusCounts' } example: object: quote_status_counts waiting: 6 accepted: 23 rejected: 4 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un preventivo" x-scope: quotes.read x-description-it: | Restituisce il preventivo con le righe (`line_items`) e i totali calcolati. Se il preventivo è già stato convertito, `worksheet_id` punta alla scheda lavoro generata. 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`. x-description-it: "Il preventivo richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' comments: Valido 30 giorni. line_items: - object: quote_line_item id: 2e6b8d40-7f19-4c53-a802-9d4e1b7c3f56 article_id: null description: Manodopera preventivata quantity: 2 unit_type: pz unit_price: 35.0 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 position: 1 created_at: '2026-07-10T09:20:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Quotes] summary: Update quote metadata x-summary-it: "Aggiorna un preventivo" x-scope: quotes.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati, e `null` svuota il campo. Per cambiare stato usa `POST /v1/quotes/{id}/transitions`, non questo endpoint. 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. x-description-it: "Il preventivo aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' comments: Valido 30 giorni. line_items: - object: quote_line_item id: 2e6b8d40-7f19-4c53-a802-9d4e1b7c3f56 article_id: null description: Manodopera preventivata quantity: 2 unit_type: pz unit_price: 35.0 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 position: 1 created_at: '2026-07-10T09:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un preventivo" x-scope: quotes.write x-description-it: | Eliminazione logica: il preventivo sparisce dagli elenchi. La risposta contiene il preventivo con `deleted_at` valorizzato. 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. x-description-it: "Il preventivo eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Converti in scheda lavoro" x-scope: quotes.write x-description-it: | Genera una scheda lavoro dal preventivo, copiandone righe e totali, e collega i due documenti (`worksheet_id` sul preventivo). Attenzione: la risposta è la **scheda lavoro** creata, non il preventivo — l'oggetto restituito ha `object: worksheet`. La conversione è una sola volta: un preventivo già convertito risponde `409`. 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). x-description-it: "La scheda lavoro generata dal preventivo." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: draft title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 frozen: false created_at: '2026-07-11T08:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Modifica le righe in blocco" x-scope: quotes.write x-description-it: | Aggiunge, aggiorna, rimuove e riordina le righe in una sola richiesta, applicata come singola transazione: se una operazione fallisce non viene applicata nessuna. I totali del preventivo vengono ricalcolati e restituiti. 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`). x-description-it: "Il preventivo con le righe aggiornate e i totali ricalcolati." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: waiting title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' comments: Valido 30 giorni. line_items: - object: quote_line_item id: 2e6b8d40-7f19-4c53-a802-9d4e1b7c3f56 article_id: null description: Manodopera preventivata quantity: 2 unit_type: pz unit_price: 35.0 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 position: 1 created_at: '2026-07-10T09:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Cambia stato al preventivo" x-scope: quotes.write x-description-it: | Porta il preventivo in `waiting`, `accepted` o `rejected`. È il modo corretto di registrare l'esito: non impostare `status` via PATCH. Accettare un preventivo non crea da solo la scheda lavoro — per quello serve `POST /v1/quotes/{id}/convert`. 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. x-description-it: "Il preventivo dopo la transizione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Quote' } example: object: quote id: b93f7c25-4a18-4e60-9d37-8c1b5f2a6e04 seq_number: 87 status: accepted title: Preventivo tagliando customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: null work_category_name: null km: 61240 worksheet_id: null worksheet_seq_number: null total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 total_discount: 0 hide_discounts: false created_at: '2026-07-10T09:14:00Z' updated_at: '2026-07-10T09:20:00Z' comments: Valido 30 giorni. line_items: - object: quote_line_item id: 2e6b8d40-7f19-4c53-a802-9d4e1b7c3f56 article_id: null description: Manodopera preventivata quantity: 2 unit_type: pz unit_price: 35.0 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 70.0 total_tax: 15.4 total_after_tax: 85.4 position: 1 created_at: '2026-07-10T09:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Elenca gli scontrini" x-scope: receipts.read x-description-it: | Gli scontrini dell'officina, dal più recente. Filtra per cliente con `q` e per periodo con `from` / `to`. A differenza degli altri elenchi questo non è paginato: restituisce tutti gli scontrini del periodo e `has_more` è sempre `false`. parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: sdi_status in: query required: false description: Filtra per stato di trasmissione allo SdI. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: 'Receipts + date_filter.' x-description-it: "Una pagina di scontrini." content: application/json: example: object: list data: - object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: issued status: sale seq_number: 58 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null issued_at: '2026-07-17T08:20:00Z' voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/receipts date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Receipts] summary: Create a receipt from a worksheet x-summary-it: "Crea uno scontrino" x-scope: receipts.write x-description-it: | Lo scontrino nasce sempre da una scheda lavoro: passa `worksheet_id` e righe, totali e anagrafiche vengono copiati da lì. Una scheda lavoro può generare un solo scontrino: se ne esiste già uno la richiesta risponde `409`. description: Body {worksheet_id, payment_method}. 409 when the worksheet already has a receipt. requestBody: required: true content: application/json: schema: type: object required: [worksheet_id, payment_method] properties: worksheet_id: type: string description: La scheda lavoro da cui generare lo scontrino. payment_method: type: string description: Codice ministeriale, es. `MP01` contanti. example: worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 payment_method: MP01 responses: '201': description: 'Receipt (line items included).' x-description-it: "Lo scontrino creato, in bozza." content: application/json: example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: draft status: sale title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' seq_number: null issued_at: null '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/ValidationError' } '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) x-summary-it: "Recupera uno scontrino" x-scope: receipts.read x-description-it: | Restituisce lo scontrino con le righe (`line_items`) e i totali. Il campo `type` è derivato e vale `draft`, `issued` o `voided`. responses: '200': description: 'Receipt.' x-description-it: "Lo scontrino richiesto." content: application/json: example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: issued status: sale seq_number: 58 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null issued_at: '2026-07-17T08:20:00Z' voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' line_items: - object: receipt_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_price: 74.0 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Aggiorna uno scontrino" x-scope: receipts.write x-description-it: | Modifica i campi descrittivi di uno scontrino ancora in bozza. Aggiorna solo i campi presenti nel corpo: gli altri restano invariati. requestBody: required: true content: application/json: schema: type: object properties: title: { type: [string, 'null'] } comments: { type: [string, 'null'] } internal_comments: { type: [string, 'null'] } email: { type: [string, 'null'] } payment_method: { type: [string, 'null'] } sezionale_id: { type: [string, 'null'] } example: title: Scontrino tagliando payment_method: MP08 responses: '200': description: 'Updated receipt.' x-description-it: "Lo scontrino aggiornato." content: application/json: example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: draft status: sale title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' seq_number: null issued_at: null line_items: - object: receipt_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_price: 74.0 '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Elimina uno scontrino" x-scope: receipts.write x-description-it: | Eliminazione logica, possibile finché lo scontrino è in bozza. Uno scontrino già emesso va prima annullato dal gestionale web: l'annullamento non è esposto su questa API. responses: '200': description: 'The removed receipt.' x-description-it: "Lo scontrino eliminato." content: application/json: example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: draft status: sale title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' seq_number: null issued_at: null deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Emetti uno scontrino" x-scope: receipts.write x-description-it: | Assegna il numero progressivo e valorizza `issued_at`. Vale solo per la numerazione cartacea: se l'officina ha i corrispettivi elettronici attivi (`electronic_receipts_enabled`) la richiesta risponde `409`, perché in quel caso l'emissione passa dal registratore telematico e non da questa API. requestBody: required: true content: application/json: schema: type: object required: [payment_method] properties: amount: type: [number, 'null'] description: Se omesso vale il residuo da incassare. payment_method: { type: string } payment_date: type: string description: Formato `YYYY-MM-DD`. financial_account_id: { type: string } example: amount: 150.0 payment_method: MP01 payment_date: '2026-07-16' responses: '200': description: 'Issued receipt.' x-description-it: "Lo scontrino emesso, con numero e data." content: application/json: example: object: receipt id: 3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19 type: issued status: sale seq_number: 58 title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD worksheet_id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 sezionale_id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 sezionale_code: B payment_method: MP01 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_payments: 285.36 outstanding_payments: 0 electronic_receipts_enabled: false e_receipt_status: null issued_at: '2026-07-17T08:20:00Z' voided_at: null created_at: '2026-07-17T08:00:00Z' updated_at: '2026-07-17T08:20:00Z' line_items: - object: receipt_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 vat_code: '22' tax_rate: 22 total_price: 74.0 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca i pagamenti dello scontrino" x-scope: receipts.read x-description-it: | Gli incassi registrati sullo scontrino. Il residuo è `outstanding_payments` sullo scontrino stesso. responses: '200': description: 'Payments, oldest first (unpaginated).' x-description-it: "I pagamenti dello scontrino." content: application/json: example: object: list data: - object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 285.36 payment_method: MP01 payment_date: '2026-07-17' created_at: '2026-07-17T08:20:00Z' has_more: false url: /v1/receipts/3d7f1b95-6c28-4a04-9e51-8b2f7a3c6d19/payments '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Registra un pagamento" x-scope: receipts.write x-description-it: | Registra un incasso sullo scontrino. `payment_method` usa i codici ministeriali (`MP01` contanti, `MP05` bonifico, `MP08` carta); se omesso vale quello dello scontrino. L'importo può essere parziale. requestBody: required: true content: application/json: schema: type: object required: [payment_method] properties: amount: type: [number, 'null'] description: Se omesso vale il residuo da incassare. payment_method: { type: string } payment_date: type: string description: Formato `YYYY-MM-DD`. financial_account_id: { type: string } example: amount: 150.0 payment_method: MP01 payment_date: '2026-07-16' responses: '201': description: 'Payment.' x-description-it: "Il pagamento registrato." content: application/json: example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 285.36 payment_method: MP01 payment_date: '2026-07-17' created_at: '2026-07-17T08:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un pagamento" x-scope: receipts.write x-description-it: | Rimuove l'incasso e riporta il residuo dello scontrino al valore precedente. responses: '200': description: 'The removed payment.' x-description-it: "Il pagamento eliminato." content: application/json: example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 285.36 payment_method: MP01 payment_date: '2026-07-17' created_at: '2026-07-17T08:20:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un widget di report" x-scope: reports.read x-description-it: | Restituisce i dati di un singolo widget delle statistiche officina. Il nome del widget va nel percorso; l'intervallo si sceglie con `preset` oppure con `from` / `to`. La risposta è una busta uniforme: `widget` ripete il widget richiesto, `preset`, `from` e `to` riportano l'intervallo effettivamente applicato e `data` contiene il risultato, la cui forma dipende dal widget. 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. x-description-it: "I dati del widget richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/ReportWidgetEnvelope' } example: object: report_widget widget: revenue_by_month preset: year from: '2026-01-01' to: '2026-12-31' data: - label: '2026-06' value: 12480.5 - label: '2026-07' value: 15230.0 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '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 x-summary-it: "Elenca i sezionali" x-scope: settings.read x-description-it: | I registri di numerazione configurati dall'officina, da usare per valorizzare `sezionale_id`. È una voce di Impostazioni, quindi richiede il permesso `settings.read`. 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. x-description-it: "I sezionali configurati." 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 } example: object: list data: - object: sezionale id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 code: A label: Sezionale principale - object: sezionale id: 5a9c2e71-4b83-40df-9126-7e3d1f6a8c25 code: B label: Corrispettivi has_more: false url: /v1/sezionali '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Settings] summary: Create a sezionale x-summary-it: "Crea un sezionale" x-scope: settings.write x-description-it: | Crea un registro di numerazione. `code` è il suffisso che compare sul numero del documento. operationId: sezionaleCreate requestBody: required: true content: application/json: schema: type: object required: [code, label] properties: code: { type: string } label: { type: string } example: code: A label: Sezionale principale responses: '201': description: success x-description-it: "Il sezionale creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: sezionale id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 code: A label: Sezionale principale '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/ValidationError' } '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 x-summary-it: "Elimina un sezionale" x-scope: settings.write x-description-it: | Elimina il sezionale. Non è eliminabile se è già stato usato per numerare un documento. operationId: sezionaleDelete responses: '200': description: success x-description-it: "Il sezionale eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: sezionale id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Settings] summary: Update sezionale x-summary-it: "Aggiorna un sezionale" x-scope: settings.write x-description-it: | Aggiorna codice ed etichetta del sezionale. operationId: sezionalePatch requestBody: required: true description: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. content: application/json: schema: type: object required: [code, label] properties: code: { type: string } label: { type: string } example: code: A label: Sezionale principale responses: '200': description: success x-description-it: "Il sezionale aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: sezionale id: c81f4a26-7d09-4e35-b6a1-2f8c5d3e9047 code: A label: Sezionale principale '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/suppliers: parameters: [{ $ref: '#/components/parameters/APIVersion' }] get: tags: [Suppliers] summary: List suppliers (?q= name substring, cursor pagination) x-summary-it: "Elenca i fornitori" x-scope: suppliers.read x-description-it: | I fornitori dell'officina. Filtra per testo con `q`. parameters: - name: direction in: query required: false description: "Direzione di ordinamento: `asc` o `desc`." schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: sort in: query required: false description: Colonna di ordinamento. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } responses: '200': description: 'Suppliers.' x-description-it: "I fornitori dell'officina." content: application/json: example: object: list data: - object: supplier id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' phone_national: '02 1234 5678' address: Via Mecenate 12 city: Milano zip_code: '20138' tax_id: '01234567890' comments: null bank_account_iban: 'IT60X0542811101000000123456' website: https://ricambi.example.it website_display: ricambi.example.it supplier_type: ricambi created_at: '2025-02-11T10:00:00Z' updated_at: '2026-06-03T14:20:00Z' has_more: false url: /v1/suppliers '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Suppliers] summary: Create a supplier x-summary-it: "Crea un fornitore" x-scope: suppliers.write x-description-it: | Crea un fornitore. Il telefono va in formato E.164 (`+390212345678`) e il sito con lo schema completo (`https://...`). requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } email: { type: [string, 'null'] } phone: type: [string, 'null'] description: In formato E.164, es. `+390212345678`. 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: URL completo, es. `https://ricambi.example.it`. example: name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' city: Milano zip_code: '20100' tax_id: '01234567890' website: https://ricambi.example.it responses: '201': description: 'Supplier.' x-description-it: "Il fornitore creato." content: application/json: example: object: supplier id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' phone_national: '02 1234 5678' address: Via Mecenate 12 city: Milano zip_code: '20138' tax_id: '01234567890' comments: null bank_account_iban: 'IT60X0542811101000000123456' website: https://ricambi.example.it website_display: ricambi.example.it supplier_type: ricambi created_at: '2025-02-11T10:00:00Z' updated_at: '2026-06-03T14:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un fornitore" x-scope: suppliers.read x-description-it: | Restituisce il fornitore con i riepiloghi collegati: ordini, articoli forniti e spese. responses: '200': description: 'Supplier.' x-description-it: "Il fornitore richiesto." content: application/json: example: object: supplier id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' phone_national: '02 1234 5678' address: Via Mecenate 12 city: Milano zip_code: '20138' tax_id: '01234567890' comments: null bank_account_iban: 'IT60X0542811101000000123456' website: https://ricambi.example.it website_display: ricambi.example.it supplier_type: ricambi created_at: '2025-02-11T10:00:00Z' updated_at: '2026-06-03T14:20:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Suppliers] summary: Update a supplier x-summary-it: "Aggiorna un fornitore" x-scope: suppliers.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati. requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } email: { type: [string, 'null'] } phone: type: [string, 'null'] description: In formato E.164, es. `+390212345678`. 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: URL completo, es. `https://ricambi.example.it`. example: name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' city: Milano zip_code: '20100' tax_id: '01234567890' website: https://ricambi.example.it responses: '200': description: 'Updated supplier.' x-description-it: "Il fornitore aggiornato." content: application/json: example: object: supplier id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' phone_national: '02 1234 5678' address: Via Mecenate 12 city: Milano zip_code: '20138' tax_id: '01234567890' comments: null bank_account_iban: 'IT60X0542811101000000123456' website: https://ricambi.example.it website_display: ricambi.example.it supplier_type: ricambi created_at: '2025-02-11T10:00:00Z' updated_at: '2026-06-03T14:20:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } delete: tags: [Suppliers] summary: Delete a supplier x-summary-it: "Elimina un fornitore" x-scope: suppliers.write x-description-it: | Eliminazione logica del fornitore. Ordini e spese già registrati restano intatti. responses: '200': description: 'The removed supplier.' x-description-it: "Il fornitore eliminato." content: application/json: example: object: supplier id: 6f2b8c14-9d70-4a35-b8e2-1c5f7a3d9e60 name: Ricambi Lombardia S.r.l. email: ordini@ricambi.example.it phone: '+390212345678' phone_national: '02 1234 5678' address: Via Mecenate 12 city: Milano zip_code: '20138' tax_id: '01234567890' comments: null bank_account_iban: 'IT60X0542811101000000123456' website: https://ricambi.example.it website_display: ricambi.example.it supplier_type: ricambi created_at: '2025-02-11T10:00:00Z' updated_at: '2026-06-03T14:20:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca i collaboratori" x-scope: settings.read x-description-it: | I collaboratori dell'officina, con ruolo e stato di attivazione. operationId: teamList description: | (read-only; invites and permissions stay web-side). responses: '200': description: success x-description-it: "I collaboratori dell'officina." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: team_member id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 first_name: Luca last_name: Bianchi email: luca.bianchi@example.it phone: '+393337654321' role: meccanico deactivated: false is_self: false has_more: false url: /v1/team '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un collaboratore" x-scope: settings.read x-description-it: | Restituisce il collaboratore con i gruppi di permessi assegnati. 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 x-description-it: "Il collaboratore richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: team_member id: 4e7b1d29-5a63-40c8-9f15-2b8d6e3a1c74 first_name: Luca last_name: Bianchi email: luca.bianchi@example.it phone: '+393337654321' role: meccanico deactivated: false is_self: false permission_groups: - name: Officina granted: true '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/tyresets: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [TyreSets] summary: List tyre sets x-summary-it: "Elenca i treni in deposito" x-scope: articles.read x-description-it: | I treni di gomme custoditi per i clienti. Filtra per stato con `status` e per testo con `q`. Il deposito gomme rientra nel permesso `articles`, non ne ha uno proprio: una chiave con `articles.read` vede sia il magazzino sia il deposito. operationId: tyreSetList description: | ?status, ?customer_id, cursor pagination). parameters: - name: customer_id in: query required: false description: Filtra per cliente. schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: location in: query required: false description: Filtra per posizione di deposito. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: rim_type in: query required: false description: Filtra per tipo di cerchio. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } responses: '200': description: success x-description-it: "I treni in deposito." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: deposited description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: null collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' has_more: false url: /v1/tyresets '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [TyreSets] summary: Create a tyre set x-summary-it: "Registra un treno in deposito" x-scope: articles.write x-description-it: | Prende in custodia un treno di gomme per un cliente. Servono `customer_id`, `vehicle_id`, `description` e `rim_type`. operationId: tyreSetCreate description: | customer's vehicle (status starts deposited, web parity). requestBody: required: true content: application/json: schema: type: object required: [customer_id, vehicle_id, description, rim_type] 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'] } example: customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive residual_mm: 6 location: Scaffale B3 responses: '201': description: success x-description-it: "Il treno registrato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: deposited description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: null collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/tyresets/status-counts: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [TyreSets] summary: Replace tyre status counts x-summary-it: "Conta i treni per stato" x-scope: articles.read x-description-it: | Il numero di treni in ciascuno stato, con gli stessi filtri dell'elenco. operationId: tyreSetStatusCounts parameters: - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di treni per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set_status_counts deposited: 18 mounted: 4 collected: 61 disposed: 3 locations: - Scaffale B3 - Magazzino esterno '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un treno dal deposito" x-scope: articles.write x-description-it: | Eliminazione logica del treno in deposito. operationId: tyreSetDelete responses: '200': description: success x-description-it: "Il treno eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: deposited description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: null collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } get: tags: [TyreSets] summary: Retrieve a tyre set x-summary-it: "Recupera un treno in deposito" x-scope: articles.read x-description-it: | Restituisce il treno con le date dei passaggi di stato: `deposited_at`, `mounted_at`, `collected_at`, `disposed_at`. operationId: tyreSetGet responses: '200': description: success x-description-it: "Il treno richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: deposited description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: null collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [TyreSets] summary: Update tyre set x-summary-it: "Aggiorna un treno in deposito" x-scope: articles.write x-description-it: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. operationId: tyreSetPatch description: | the descriptive fields (status moves via transitions). requestBody: required: true description: | Il corpo è lo stesso della creazione: va inviato l'oggetto completo, non solo i campi da cambiare. content: application/json: schema: type: object required: [customer_id, vehicle_id, description, rim_type] 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'] } example: customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive residual_mm: 6 location: Scaffale B3 responses: '200': description: success x-description-it: "Il treno aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: deposited description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: null collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '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 x-summary-it: "Cambia stato al treno" x-scope: articles.write x-description-it: | Registra un passaggio di stato (montato, ritirato, smaltito) e valorizza la data corrispondente. È l'unico modo di cambiare `status`: non impostarlo via PATCH, perché non aggiornerebbe le date. operationId: tyreSetTransition description: | {action: deposited|mounted|disposed|collected}. requestBody: required: true content: application/json: schema: type: object required: [action] properties: action: { type: string } example: action: ritirato responses: '200': description: success x-description-it: "Il treno dopo il passaggio di stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: tyre_set id: 47e2c9b1-8d50-4f36-a127-6c3b9e5d2a84 seq_number: 63 status: mounted description: Michelin Primacy 195/65 R15 quantity: 4 rim_type: lega season_type: estive condition: buono residual_mm: 6 location: Scaffale B3 comments: null customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD deposited_at: '2026-04-10T09:00:00Z' mounted_at: '2026-08-04T09:00:00Z' collected_at: null disposed_at: null inventory_check_at: null created_at: '2026-04-10T09:00:00Z' updated_at: '2026-04-10T09:00:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/vat-rates: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Articles] summary: List company VAT rates x-summary-it: "Elenca le aliquote IVA" x-scope: settings.read x-description-it: | Le aliquote attive dell'azienda, da usare per valorizzare `vat_code` sugli articoli e sulle righe dei documenti. È una voce di Impostazioni, quindi richiede il permesso `settings.read`. 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. x-description-it: "Le aliquote IVA attive." 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 } example: object: list data: - object: vat_rate code: '22' rate: 22 description: Aliquota ordinaria - object: vat_rate code: '10' rate: 10 description: Aliquota ridotta - object: vat_rate code: 'N2.2' rate: 0 description: Non soggetta has_more: false url: /v1/vat-rates '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } put: tags: [Settings] summary: Replace vat rate x-summary-it: "Imposta le aliquote IVA attive" x-scope: settings.write x-description-it: | Sostituisce l'intero insieme delle aliquote attive: quelle non elencate vengono disattivate. I codici ammessi sono quelli di `GET /v1/vat-rates/catalog`. operationId: vatRateSet description: | set with the given ordered codes. requestBody: required: true content: application/json: schema: type: object required: [codes] properties: codes: type: array description: | L'insieme completo delle aliquote attive. Le aliquote non elencate vengono disattivate. items: { type: string } example: codes: - '22' - '10' - '4' responses: '200': description: success x-description-it: "Le aliquote ora attive." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: vat_rates codes: - '22' - '10' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/vat-rates/catalog: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Settings] summary: Vat rate catalog x-summary-it: "Catalogo delle aliquote IVA" x-scope: settings.read x-description-it: | Tutte le aliquote disponibili, con `enabled` a indicare quelle attive per l'officina. operationId: vatRateCatalog description: | global catalog with the company's enabled marks. responses: '200': description: success x-description-it: "Il catalogo delle aliquote." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: vat_rate_catalog_entry code: '22' rate: 22 description: Aliquota ordinaria enabled: true - object: vat_rate_catalog_entry code: '10' rate: 10 description: Aliquota ridotta enabled: true - object: vat_rate_catalog_entry code: 'N2.2' rate: 0 description: Non soggetta enabled: false has_more: false url: /v1/vat-rates/catalog '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } /v1/vehicle-field-settings: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Settings] summary: Retrieve a vehicle field settings x-summary-it: "Recupera i campi veicolo" x-scope: settings.read x-description-it: | Quali campi facoltativi della scheda veicolo sono visibili e quali campi personalizzati sono definiti. operationId: vehicleFieldSettingsGet responses: '200': description: success x-description-it: "La configurazione dei campi veicolo." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: vehicle_field_settings field_visibility: km: true numero_telaio: true transmission: false engine_category: true engine_power_kw: false engine_displacement_ccm: false vehicle_weight_kg: false registration_date: true last_inspection_date: true next_inspection_date: true custom_fields: - id: 8a2d6f41-9c30-4e75-b168-3f7a5c2e9d04 name: Colore type: string '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } put: tags: [Settings] summary: Replace vehicle field settings x-summary-it: "Imposta i campi veicolo" x-scope: settings.write x-description-it: | Sostituisce l'intera configurazione: i campi personalizzati non elencati vengono rimossi. operationId: vehicleFieldSettingsPut description: | replaces visibility toggles and custom field definitions. requestBody: required: true content: application/json: schema: type: object required: [field_visibility] properties: field_visibility: type: object description: | Quali campi facoltativi restano visibili nella scheda veicolo. 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 } custom_fields: type: array description: | L'insieme completo dei campi personalizzati. Quelli non elencati vengono rimossi. items: type: object required: [name, type] properties: id: { type: string } name: { type: string } type: { type: string, enum: [string, integer, datetime] } example: field_visibility: km: true numero_telaio: true transmission: false engine_category: true engine_power_kw: false engine_displacement_ccm: false vehicle_weight_kg: false registration_date: true last_inspection_date: true next_inspection_date: true custom_fields: - name: Colore type: string responses: '200': description: success x-description-it: "La configurazione aggiornata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: vehicle_field_settings field_visibility: km: true numero_telaio: true transmission: false engine_category: true engine_power_kw: false engine_displacement_ccm: false vehicle_weight_kg: false registration_date: true last_inspection_date: true next_inspection_date: true custom_fields: - id: 8a2d6f41-9c30-4e75-b168-3f7a5c2e9d04 name: Colore type: string '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/vehicle-sales: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Dealership] summary: List vehicle sales x-summary-it: "Elenca le vendite veicoli" x-scope: reports.read x-description-it: | Le vendite di veicoli, con margine (`total_profit`) e riferimento alla fattura emessa. 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) parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } - name: status in: query required: false description: Filtra per stato. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Le vendite veicoli." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: vehicle_sale id: 5b9d3e70-2c41-48a6-9e15-7f2a6c8b4d13 seq_number: 24 status: delivered customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sale_date: '2026-06-19' total_after_tax: 6500.0 total_profit: 1820.0 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 comments: null has_more: false url: /v1/vehicle-sales date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/vehicle-sales/status-counts: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Dealership] summary: Vehicle sale status counts x-summary-it: "Conta le vendite per stato" x-scope: reports.read x-description-it: | Il numero di vendite in ciascuno stato, con gli stessi filtri dell'elenco. operationId: vehicleSaleStatusCounts parameters: - name: from in: query required: false description: Inizio dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: to in: query required: false description: Fine dell'intervallo, formato `YYYY-MM-DD`. schema: { type: string } responses: '200': description: success x-description-it: "Il numero di vendite per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: vehicle_sale_status_counts draft: 1 signed: 2 delivered: 21 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera una vendita veicolo" x-scope: reports.read x-description-it: | Restituisce la singola vendita. operationId: vehicleSaleGet responses: '200': description: success x-description-it: "La vendita richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: vehicle_sale id: 5b9d3e70-2c41-48a6-9e15-7f2a6c8b4d13 seq_number: 24 status: delivered customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_name: Fiat Panda vehicle_license: AB123CD sale_date: '2026-06-19' total_after_tax: 6500.0 total_profit: 1820.0 invoice_id: 4412e7b9-0c35-4a18-92d7-5b6ea1f38c04 comments: null '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } /v1/vehicle-stocks: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Dealership] summary: List vehicle stocks x-summary-it: "Elenca i veicoli in stock" x-scope: reports.read x-description-it: | I veicoli acquistati per la rivendita, con costo di acquisto, costo totale sostenuto e giorni di giacenza. La concessionaria rientra nel permesso `reports`, non ne ha uno proprio. operationId: vehicleStockList parameters: - name: limit in: query required: false description: Numero di risultati per pagina. schema: { type: integer } - name: q in: query required: false description: Ricerca testuale libera. schema: { type: string } - name: starting_after in: query required: false description: Cursore opaco della pagina precedente. Va restituito invariato. schema: { type: string } responses: '200': description: success x-description-it: "I veicoli in stock." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: list data: - object: vehicle_stock id: 3e9a7c12-5b48-40d6-8f27-1c4b9e2a6d35 seq_number: 18 vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD purchase_date: '2026-05-04' purchase_cost: 4200.0 total_cost: 4680.0 sale_price: 6500.0 days_in_stock: 92 sold: false has_more: false url: /v1/vehicle-stocks '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/vehicles: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Vehicles] summary: List vehicles x-summary-it: "Elenca i veicoli" x-scope: vehicles.read x-description-it: | Restituisce i veicoli dell'officina, dal più recente. Filtra per testo con `q` (modello o targa) e per proprietario con `customer_id`. La paginazione usa un cursore opaco: passa `next_starting_after` invariato in `starting_after` per la pagina successiva e usa `has_more` per sapere se fermarti. 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. x-description-it: "Una pagina di veicoli." 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' } example: object: list data: - object: vehicle id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 license: AB123CD manufacturer_id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 manufacturer_name: Fiat model: Panda variant: 1.2 Lounge vin: ZFA31200003456789 transmission: manuale engine_power_kw: 51 engine_displacement_ccm: 1242 engine_category: Euro 6 registered_at: '2019-06-12T00:00:00Z' customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi km: 84210 km_registered_at: '2026-07-14T09:12:00Z' last_inspection_at: '2025-06-01T00:00:00Z' next_inspection_at: '2027-06-01T00:00:00Z' is_rental: false created_at: '2024-03-02T10:14:33Z' updated_at: '2026-07-14T09:12:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/vehicles '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Vehicles] summary: Create a vehicle x-summary-it: "Crea un veicolo" x-scope: vehicles.write x-description-it: | Crea un veicolo. Sono obbligatori `manufacturer_id` e `model`; la marca deve essere fra quelle attive per l'officina (vedi `GET /v1/manufacturers`). Collega il veicolo a un cliente con `customer_id`: può restare vuoto e venire assegnato in seguito. 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: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 model: Panda license: AB123CD customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd km: 84210.5 responses: '201': description: Vehicle created. x-description-it: "Il veicolo creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Vehicle' } example: object: vehicle id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 license: AB123CD manufacturer_id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 manufacturer_name: Fiat model: Panda variant: 1.2 Lounge vin: ZFA31200003456789 transmission: manuale engine_power_kw: 51 engine_displacement_ccm: 1242 engine_category: Euro 6 registered_at: '2019-06-12T00:00:00Z' customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi km: 84210 km_registered_at: '2026-07-14T09:12:00Z' last_inspection_at: '2025-06-01T00:00:00Z' next_inspection_at: '2027-06-01T00:00:00Z' is_rental: false created_at: '2024-03-02T10:14:33Z' updated_at: '2026-07-14T09:12:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera un veicolo" x-scope: vehicles.read x-description-it: | Restituisce un singolo veicolo. Rispetto all'elenco include anche i documenti allegati, i campi personalizzati risolti (`custom_field_values`) e il primo appuntamento in agenda (`next_appointment`). 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. x-description-it: "Il veicolo richiesto." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Vehicle' } example: object: vehicle id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 license: AB123CD manufacturer_id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 manufacturer_name: Fiat model: Panda variant: 1.2 Lounge vin: ZFA31200003456789 transmission: manuale engine_power_kw: 51 engine_displacement_ccm: 1242 engine_category: Euro 6 registered_at: '2019-06-12T00:00:00Z' customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi km: 84210 km_registered_at: '2026-07-14T09:12:00Z' last_inspection_at: '2025-06-01T00:00:00Z' next_inspection_at: '2027-06-01T00:00:00Z' is_rental: false created_at: '2024-03-02T10:14:33Z' updated_at: '2026-07-14T09:12:00Z' next_appointment: null comments: Cliente chiede sempre olio Selenia. documents: - object: vehicle_document id: e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64 name: libretto.pdf content_type: application/pdf file_size_bytes: 148213 url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/documents/e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64/download created_at: '2026-07-14T09:20:11Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Vehicles] summary: Update a vehicle x-summary-it: "Aggiorna un veicolo" x-scope: vehicles.write x-description-it: | Aggiorna solo i campi presenti nel corpo della richiesta: i campi omessi restano invariati. Per svuotare un campo passalo esplicitamente a `null`. 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. x-description-it: "Il veicolo aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Vehicle' } example: object: vehicle id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 license: AB123CD manufacturer_id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 manufacturer_name: Fiat model: Panda variant: 1.2 Lounge vin: ZFA31200003456789 transmission: manuale engine_power_kw: 51 engine_displacement_ccm: 1242 engine_category: Euro 6 registered_at: '2019-06-12T00:00:00Z' customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi km: 84210 km_registered_at: '2026-07-14T09:12:00Z' last_inspection_at: '2025-06-01T00:00:00Z' next_inspection_at: '2027-06-01T00:00:00Z' is_rental: false created_at: '2024-03-02T10:14:33Z' updated_at: '2026-07-14T09:12:00Z' next_appointment: null comments: Cliente chiede sempre olio Selenia. documents: - object: vehicle_document id: e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64 name: libretto.pdf content_type: application/pdf file_size_bytes: 148213 url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/documents/e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64/download created_at: '2026-07-14T09:20:11Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un veicolo" x-scope: vehicles.write x-description-it: | Eliminazione logica: il veicolo sparisce dagli elenchi ma i documenti già emessi che lo citano restano intatti. 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. x-description-it: "Il veicolo eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Vehicle' } example: object: vehicle id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 license: AB123CD manufacturer_id: c3d9e8f1-4a72-4b60-8e15-9d2f7c4a6b83 manufacturer_name: Fiat model: Panda variant: 1.2 Lounge vin: ZFA31200003456789 transmission: manuale engine_power_kw: 51 engine_displacement_ccm: 1242 engine_category: Euro 6 registered_at: '2019-06-12T00:00:00Z' customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi km: 84210 km_registered_at: '2026-07-14T09:12:00Z' last_inspection_at: '2025-06-01T00:00:00Z' next_inspection_at: '2027-06-01T00:00:00Z' is_rental: false created_at: '2024-03-02T10:14:33Z' updated_at: '2026-07-14T09:12:00Z' deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Storico accettazioni del veicolo" x-scope: checkins.read x-description-it: | Le accettazioni registrate per il veicolo, dalla più recente. Richiede il permesso `checkins.read`. 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. x-description-it: "Le accettazioni del veicolo." 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 } example: object: list data: - object: checkin id: 1c7a9e04-5f83-4b26-8d10-6e2b4a9c7f31 status: convertita vehicle_id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd created_at: '2026-05-17T16:44:00Z' has_more: false url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/checkins '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Allega un documento al veicolo" x-scope: vehicles.write x-description-it: | Carica un file fra i Documenti Allegati del veicolo. La richiesta e' `multipart/form-data` con il file nel campo `file`, non JSON. 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. x-description-it: "Il documento allegato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/VehicleDocument' } example: object: vehicle_document id: e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64 name: libretto.pdf content_type: application/pdf description: Carta di circolazione file_size_bytes: 148213 url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/documents/e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64/download created_at: '2026-07-14T09:20:11Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un documento del veicolo" x-scope: vehicles.write x-description-it: | Rimuove definitivamente il file allegato. operationId: vehicleDocumentDelete description: | VehicleDocumentDelete handles DELETE /v1/vehicles/{id}/documents/{documentID}. responses: '200': description: success (the removed document) x-description-it: "Il documento eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/VehicleDocument' } example: object: vehicle_document id: e2a4b6c8-1d3f-4059-b7e8-2c9a5d1f3b64 name: libretto.pdf content_type: application/pdf file_size_bytes: 148213 created_at: '2026-07-14T09:20:11Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Scarica un documento del veicolo" x-scope: vehicles.read x-description-it: | Restituisce il contenuto del file, non JSON. È il percorso indicato dal campo `url` di ogni documento. 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Storico preventivi del veicolo" x-scope: quotes.read x-description-it: | I preventivi collegati al veicolo, dal più recente. Richiede il permesso `quotes.read`. description: | Every quote linked to the vehicle, newest first, without line items. Unpaginated (`has_more` always false). responses: '200': description: The vehicle's quotes. x-description-it: "I preventivi del veicolo." 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 } example: object: list data: - object: quote id: 8d3f5b92-2a71-4c08-9e6b-1f4a7d0c3e85 seq_number: 87 status: accettato vehicle_id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd total_after_tax: 310.0 created_at: '2026-04-02T11:05:00Z' has_more: false url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/quotes '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Storico schede lavoro del veicolo" x-scope: worksheets.read x-description-it: | Le schede lavoro del veicolo, dalla più recente. Restituisce schede lavoro, quindi richiede il permesso `worksheets.read` e non `vehicles.read`. 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. x-description-it: "Le schede lavoro del veicolo." 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 } example: object: list data: - object: worksheet id: 5e8c1a37-9d24-4f60-b3a8-7c2e9f1d4b05 seq_number: 412 status: completata vehicle_id: 7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi total_after_tax: 285.4 created_at: '2026-05-18T08:30:00Z' has_more: false url: /v1/vehicles/7b1f4a92-3c8d-4e15-9a20-5f6c8d3e1b47/worksheets '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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) x-summary-it: "Elenca i tipi di intervento" x-scope: settings.read x-description-it: | I tipi di intervento configurati dall'officina, da usare per valorizzare `work_category_id`. È una voce di Impostazioni, quindi richiede il permesso `settings.read`. 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. x-description-it: "I tipi di intervento configurati." 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 } example: object: list data: - object: work_category id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 name: Tagliando - object: work_category id: 5f2b7d94-1a63-48ec-9207-8d4e3c1f6a52 name: Freni has_more: false url: /v1/work-categories '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Settings] summary: Create a work category x-summary-it: "Crea un tipo di intervento" x-scope: settings.write x-description-it: | Crea un tipo di intervento, usato per classificare schede lavoro e preventivi. operationId: workCategoryCreate requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } example: name: Nuovo nome responses: '201': description: success x-description-it: "Il tipo di intervento creato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: work_category id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 name: Tagliando '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '422': { $ref: '#/components/responses/ValidationError' } '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 x-summary-it: "Elimina un tipo di intervento" x-scope: settings.write x-description-it: | Elimina il tipo di intervento. I documenti che lo citano restano intatti. operationId: workCategoryDelete responses: '200': description: success x-description-it: "Il tipo di intervento eliminato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: work_category id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 name: Tagliando '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Settings] summary: Update work category x-summary-it: "Aggiorna un tipo di intervento" x-scope: settings.write x-description-it: | Aggiorna il nome del tipo di intervento. operationId: workCategoryPatch requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } example: name: Nuovo nome responses: '200': description: success x-description-it: "Il tipo di intervento aggiornato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: example: object: work_category id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 name: Tagliando '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } /v1/worksheets: parameters: - $ref: '#/components/parameters/APIVersion' get: tags: [Worksheets] summary: List worksheets x-summary-it: "Elenca le schede lavoro" x-scope: worksheets.read x-description-it: | Le schede lavoro dell'officina, dalla più recente. Filtra per stato con `status`, per cliente con `customer_id`, per veicolo con `vehicle_id` e per periodo con `from` / `to`. La paginazione usa un cursore opaco: passa `next_starting_after` invariato in `starting_after` e fermati quando `has_more` è `false`. 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 } - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Page of worksheets (without line items). x-description-it: "Una pagina di schede lavoro." 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' } example: object: list data: - object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' has_more: true next_starting_after: 'eyJvIjoyMH0' url: /v1/worksheets date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '422': { $ref: '#/components/responses/ValidationError' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Worksheets] summary: Create a worksheet x-summary-it: "Crea una scheda lavoro" x-scope: worksheets.write x-description-it: | Crea una scheda lavoro in stato `draft`. Il numero progressivo (`seq_number`) è assegnato dal server. Le righe non si passano qui: crea la scheda, poi aggiungile con `POST /v1/worksheets/{id}/line-items/batch`. 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. x-description-it: "La scheda lavoro creata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Conta le schede per stato" x-scope: worksheets.read x-description-it: | Il numero di schede in ciascuno stato, con gli stessi filtri dell'elenco. Serve a popolare i contatori senza scaricare le schede. 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' - name: preset in: query required: false description: Intervallo predefinito (es. `month`, `year`). Alternativo a `from`/`to`. schema: { type: string } responses: '200': description: Counts per status. x-description-it: "Il numero di schede per stato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/WorksheetStatusCounts' } example: object: worksheet_status_counts draft: 3 waiting: 5 started: 2 completed: 8 delivered: 41 archived: 120 date_filter: object: date_filter preset: year from: '2026-01-01' to: '2026-12-31' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Recupera una scheda lavoro" x-scope: worksheets.read x-description-it: | Restituisce la scheda con le righe (`line_items`), i documenti allegati e i totali calcolati. `frozen` indica che la scheda ha già generato un documento fiscale e non è più modificabile; `linked_invoices` e `linked_receipt` puntano ai documenti emessi. 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`. x-description-it: "La scheda lavoro richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' comments: Sostituire anche il filtro abitacolo. line_items: - object: work_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 article_id: null description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-15T08:31:00Z' - object: work_line_item id: 0b4a2d6f-1c3e-4f5a-8b9c-7d6e5f4a3b2c article_id: null description: Manodopera tagliando quantity: 2 unit_type: pz unit_price: 79.95 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 159.9 total_tax: 35.18 total_after_tax: 195.08 position: 1 created_at: '2026-07-15T08:31:00Z' documents: [] '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalError' } patch: tags: [Worksheets] summary: Update a worksheet x-summary-it: "Aggiorna una scheda lavoro" x-scope: worksheets.write x-description-it: | Aggiorna solo i campi presenti nel corpo: gli altri restano invariati, e `null` svuota il campo. Una scheda `frozen` (già fatturata) rifiuta la modifica con `409`. 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. x-description-it: "La scheda lavoro aggiornata." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' comments: Sostituire anche il filtro abitacolo. line_items: - object: work_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 article_id: null description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-15T08:31:00Z' - object: work_line_item id: 0b4a2d6f-1c3e-4f5a-8b9c-7d6e5f4a3b2c article_id: null description: Manodopera tagliando quantity: 2 unit_type: pz unit_price: 79.95 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 159.9 total_tax: 35.18 total_after_tax: 195.08 position: 1 created_at: '2026-07-15T08:31:00Z' documents: [] '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina una scheda lavoro" x-scope: worksheets.write x-description-it: | Eliminazione logica. Una scheda che ha già generato una fattura o uno scontrino non può essere eliminata: la richiesta risponde `409` finché il documento fiscale esiste. 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. x-description-it: "Conferma di eliminazione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km deleted_at: '2026-07-20T11:04:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Accettazioni collegate alla scheda" x-scope: checkins.read x-description-it: | Le accettazioni da cui la scheda è stata generata. Restituisce accettazioni, quindi richiede il permesso `checkins.read` e non `worksheets.read`. 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. x-description-it: "Le accettazioni collegate alla scheda." 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 } example: object: list data: - object: checkin id: 1c7a9e04-5f83-4b26-8d10-6e2b4a9c7f31 status: convertita vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd created_at: '2026-07-15T07:55:00Z' has_more: false url: /v1/worksheets/a5d21e64-8f37-4b90-ae12-3c7d6b048f95/checkins '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Allega un documento alla scheda" x-scope: worksheets.write x-description-it: | Carica un file fra i documenti della scheda. La richiesta è `multipart/form-data` con il file nel campo `file`, non JSON. operationId: worksheetDocumentCreate description: | multipart/form-data: file required — the document bytes (max 25MB) description optional — display label; defaults to the file name 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: success x-description-it: "Il documento allegato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/WorksheetDocument' } example: object: worksheet_document id: 6b3e9f10-2c84-4d57-91ab-5e8f0a2d7c63 name: preventivo-firmato.pdf content_type: application/pdf description: Preventivo firmato file_size_bytes: 92418 url: /v1/worksheets/a5d21e64-8f37-4b90-ae12-3c7d6b048f95/documents/6b3e9f10-2c84-4d57-91ab-5e8f0a2d7c63/download created_at: '2026-07-15T09:40:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un documento della scheda" x-scope: worksheets.write x-description-it: | Rimuove definitivamente il file allegato. operationId: worksheetDocumentDelete description: | WorksheetDocumentDelete handles DELETE /v1/worksheets/{id}/documents/{documentID}. responses: '200': description: success (the removed document) x-description-it: "Conferma di eliminazione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/WorksheetDocument' } example: object: worksheet_document id: 6b3e9f10-2c84-4d57-91ab-5e8f0a2d7c63 name: preventivo-firmato.pdf content_type: application/pdf file_size_bytes: 92418 created_at: '2026-07-15T09:40:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Scarica un documento della scheda" x-scope: worksheets.read x-description-it: | Restituisce il contenuto del file, non JSON. È il percorso indicato dal campo `url` di ogni documento. 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' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Modifica le righe in blocco" x-scope: worksheets.write x-description-it: | Aggiunge, aggiorna, rimuove e riordina le righe in una sola richiesta, applicata come singola transazione: se una operazione fallisce non viene applicata nessuna. I totali della scheda vengono ricalcolati e restituiti. Collega una riga al magazzino valorizzando `article_id`; lasciandolo `null` la riga resta libera. 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`). x-description-it: "La scheda con le righe aggiornate e i totali ricalcolati." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' comments: Sostituire anche il filtro abitacolo. line_items: - object: work_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 article_id: null description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-15T08:31:00Z' - object: work_line_item id: 0b4a2d6f-1c3e-4f5a-8b9c-7d6e5f4a3b2c article_id: null description: Manodopera tagliando quantity: 2 unit_type: pz unit_price: 79.95 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 159.9 total_tax: 35.18 total_after_tax: 195.08 position: 1 created_at: '2026-07-15T08:31:00Z' documents: [] '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elenca i pagamenti della scheda" x-scope: worksheets.read x-description-it: | Gli incassi registrati sulla scheda. Il residuo da incassare è `outstanding_payments` sulla scheda stessa. 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. x-description-it: "I pagamenti della scheda." 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 } example: object: list data: - object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP01 payment_date: '2026-07-16' created_at: '2026-07-16T10:02:00Z' has_more: false url: /v1/worksheets/a5d21e64-8f37-4b90-ae12-3c7d6b048f95/payments '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalError' } post: tags: [Worksheets] summary: Register a payment on a worksheet x-summary-it: "Registra un pagamento" x-scope: worksheets.write x-description-it: | Registra un incasso sulla scheda. `payment_method` usa i codici ministeriali (`MP01` contanti, `MP02` assegno, `MP05` bonifico, `MP08` carta). L'importo può essere parziale: registra più pagamenti fino a coprire il totale. 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. x-description-it: "Il pagamento registrato." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Payment' } example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP01 payment_date: '2026-07-16' created_at: '2026-07-16T10:02:00Z' '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Elimina un pagamento" x-scope: worksheets.write x-description-it: | Rimuove l'incasso e riporta il residuo della scheda al valore precedente. 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. x-description-it: "Conferma di eliminazione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Payment' } example: object: payment id: d47c0b58-3e91-42a6-8f05-1b9d6a3e2c74 amount: 150.0 payment_method: MP01 payment_date: '2026-07-16' created_at: '2026-07-16T10:02:00Z' '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Cambia stato alla scheda" x-scope: worksheets.write x-description-it: | Applica una transizione di stato e registra la data corrispondente (`started_at`, `completed_at`, `delivered_at`, `archived_at`). È il modo corretto di far avanzare una scheda: non impostare `status` via PATCH, perché non aggiornerebbe le date. 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. x-description-it: "La scheda dopo la transizione." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/Worksheet' } example: object: worksheet id: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 seq_number: 412 status: started title: Tagliando 60.000 km customer_id: 4c911f08-7713-4e83-bdf6-a0692a1b43fd customer_name: Mario Rossi vehicle_id: 8821f0ac-5d2b-41e7-9a10-6c3f2b7d4e55 vehicle_name: Fiat Panda vehicle_license: AB123CD work_category_id: 3a8e5c21-9d76-4f03-b418-2c6a9e7d5b40 work_category_name: Tagliando km: 61240 total_before_tax: 233.9 total_tax: 51.46 total_after_tax: 285.36 total_discount: 0 total_payments: 150.0 outstanding_payments: 135.36 fiscal_document_type: fattura frozen: false invoice_id: null receipt_id: null started_at: '2026-07-15T08:30:00Z' completed_at: null delivered_at: null created_at: '2026-07-15T08:12:00Z' updated_at: '2026-07-16T10:02:00Z' comments: Sostituire anche il filtro abitacolo. line_items: - object: work_line_item id: 7c0f6c4e-9b1a-4c2e-9a5d-2f3b1e8a4d10 article_id: null description: Olio motore 5W30 quantity: 4 unit_type: pz unit_price: 18.5 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 74.0 total_tax: 16.28 total_after_tax: 90.28 created_at: '2026-07-15T08:31:00Z' - object: work_line_item id: 0b4a2d6f-1c3e-4f5a-8b9c-7d6e5f4a3b2c article_id: null description: Manodopera tagliando quantity: 2 unit_type: pz unit_price: 79.95 unit_discount_rate: 0 tax_rate: 22 vat_code: '22' total_before_tax: 159.9 total_tax: 35.18 total_after_tax: 195.08 position: 1 created_at: '2026-07-15T08:31:00Z' documents: [] '400': { $ref: '#/components/responses/MalformedRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '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 x-summary-it: "Verifica la chiave API" x-description-it: | Restituisce la chiave che sta effettuando la richiesta: nome, ambiente, permessi concessi e azienda a cui appartiene. È l'unico endpoint senza permesso richiesto: qualunque chiave valida può chiamarlo, ed è il modo più rapido per verificare che una chiave funzioni. Un `200` significa che la chiave è valida e che il piano attivo include l'accesso API; `401` che la chiave è sconosciuta, revocata o scaduta; `402` che il piano non include l'accesso API. 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. x-description-it: "La chiave che sta effettuando la richiesta." headers: X-API-Version: { $ref: '#/components/headers/APIVersion' } content: application/json: schema: { $ref: '#/components/schemas/WhoAmI' } example: object: api_key id: 5f1a8c36-2e74-4b90-8d25-6c3f9a1e7b48 name: Gestionale contabilità environment: live scopes: - customers.read - customers.write - invoices.read company: object: company id: 1f8c4e73-2a95-4b06-9d31-7e5a2c8b6f40 name: Officina Rossi S.r.l. '401': { $ref: '#/components/responses/Unauthorized' } '402': { $ref: '#/components/responses/PaymentRequired' } '500': { $ref: '#/components/responses/InternalError' } components: # ─────────────────────────────────────────────────────────────── # Security # ─────────────────────────────────────────────────────────────── 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'] } minimum_stock: { type: [number, 'null'] } purchase_price: { type: [number, 'null'] } location: { type: [string, 'null'] } comments: { type: [string, 'null'] } supplier_id: { type: [string, 'null'] } supplier_name: { type: [string, '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: a5d21e64-8f37-4b90-ae12-3c7d6b048f95 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] seq_number: type: integer description: Progressive number. 0 until the receipt is issued. type: type: string description: "Derived: \"draft\" | \"issued\" | \"voided\"." status: type: string description: "\"sale\" | \"return\"." 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