openapi: 3.1.0
info:
  title: OrderOS API
  version: "1.0.0"
  description: |
    REST API for OrderOS — multi-tenant invoicing platform. Tenant-scoped via the
    `X-Tenant-Id` header. Auth via Sanctum personal access tokens issued at
    `Settings → Manage API Tokens` or via `POST /api/login`.

    Numbers are JSON numbers (`615.00`), not locale strings (`"615,00"`). Dates
    are ISO-8601 (`2026-05-05` for dates, full RFC-3339 for timestamps).

    Every response includes an `X-API-Version` header (12-char sha of this spec).
    Cache it on first call; if a later response shows a different value, refetch
    `GET /version` and this spec to pick up contract changes.
servers:
  - url: https://app.orderos.sk/api
    description: Production
  - url: https://dev.orderos.sk/api
    description: Development

security:
  - bearerAuth: []
    tenantHeader: []

tags:
  - { name: Versioning }
  - { name: Auth }
  - { name: Clients }
  - { name: Products }
  - { name: Invoices }
  - { name: Recurring Invoices }
  - { name: Webhooks }
  - { name: Orders }
  - { name: Dashboard }
  - { name: Devices }

paths:
  /version:
    get:
      tags: [Versioning]
      summary: Current API version + spec URLs
      description: |
        Public bootstrap endpoint. Returns a short hash of the current OpenAPI spec
        plus URLs to the machine spec and human reference. Every `/api/*` response
        also includes the same value as the `X-API-Version` header — agents should
        cache it and refetch `/version` (and the spec) when the header changes.
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      version: { type: string, example: a3f9c1e2b7d4 }
                      spec_url: { type: string, format: uri, example: 'https://app.orderos.sk/openapi.yaml' }
                      human_reference_url: { type: string, format: uri, example: 'https://app.orderos.sk/api-reference.md' }

  /login:
    post:
      tags: [Auth]
      summary: Issue an API token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
                device_name: { type: string, description: Label shown in the tokens list }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  user:
                    type: object
                    properties:
                      id: { type: integer }
                      name: { type: string }
                      email: { type: string }
                      is_super_admin: { type: boolean }
                  tenants:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        slug: { type: string }
        '422': { $ref: '#/components/responses/ValidationError' }

  /logout:
    post:
      tags: [Auth]
      summary: Revoke the current token
      responses:
        '200': { description: OK }

  /user:
    get:
      tags: [Auth]
      summary: Get the central user that owns the token
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object } } }

  /clients:
    get:
      tags: [Clients]
      summary: List clients
      parameters:
        - { name: search, in: query, schema: { type: string }, description: Matches company_name/contact_name/email/ico }
        - { name: tag, in: query, schema: { type: string } }
        - { name: per_page, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
      responses:
        '200':
          description: Paginated client list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedClients'
    post:
      tags: [Clients]
      summary: Create a client
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientInput' }
      responses:
        '201':
          description: Created
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Client' } } } } }
        '422': { $ref: '#/components/responses/ValidationError' }

  /clients/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Clients]
      summary: Get a client
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Client' } } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /products:
    get:
      tags: [Products]
      summary: List products
      parameters:
        - { name: search, in: query, schema: { type: string } }
        - { name: per_page, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: page, in: query, schema: { type: integer } }
      responses:
        '200':
          description: Paginated product list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Product' } }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/InvoiceStatusFilter' } }
        - { name: type, in: query, schema: { $ref: '#/components/schemas/InvoiceType' } }
        - { name: client_id, in: query, schema: { type: integer } }
        - { name: issued_from, in: query, schema: { type: string, format: date } }
        - { name: issued_to, in: query, schema: { type: string, format: date } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: search, in: query, description: "Matches invoice_number and the invoice's client_name snapshot.", schema: { type: string } }
        - { name: per_page, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: page, in: query, schema: { type: integer } }
      responses:
        '200':
          description: Paginated invoice list (sorted by invoice_number desc)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Invoice' } }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
    post:
      tags: [Invoices]
      summary: Create an invoice
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceInput' }
      responses:
        '201':
          description: Created (number assigned by tenant numbering format)
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } }
        '422': { $ref: '#/components/responses/ValidationError' }

  /invoices/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Invoices]
      summary: Get an invoice with items
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } }
    delete:
      tags: [Invoices]
      summary: Delete a cancelled invoice
      description: Only allowed when status is `cancelled`. Use `POST /invoices/{id}/cancel` first for issued invoices.
      responses:
        '200':
          description: Deleted; invoice number reclaimed when this was the most recently issued one
        '422':
          description: Invoice is not cancelled
          content:
            application/json:
              schema:
                type: object
                properties: { message: { type: string } }

  /invoices/{id}/issue:
    post:
      tags: [Invoices]
      summary: Issue a draft (assign number, draft → issued)
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                issue_date: { type: string, format: date, description: 'Defaults to the draft''s issue date or today. Drives date-token numbers (e.g. {MONTHSEQ}).' }
                invoice_number: { type: string, maxLength: 50, description: 'Explicit number. Omit to auto-generate. 422 if already in use for this type.' }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } } }
        '422': { description: Not a draft, or number already in use }

  /invoices/{id}/send:
    post:
      tags: [Invoices]
      summary: Email the invoice PDF
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, description: 'Override recipient. Defaults to live client.email.' }
                subject: { type: string, maxLength: 255 }
                body: { type: string, description: 'Plain text shown above the standard email content.' }
                bcc: { type: array, items: { type: string, format: email } }
      responses:
        '200': { description: Sent; status transitions issued → sent }

  /invoices/{id}/mark-paid:
    post:
      tags: [Invoices]
      summary: Record a payment
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                amount: { type: number, description: 'Optional. If omitted, marks fully paid.' }
                paid_at: { type: string, format: date-time }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } } }

  /invoices/{id}/unmark-paid:
    post:
      tags: [Invoices]
      summary: Clear payment and reset status to "issued"
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } } }

  /invoices/{id}/duplicate:
    post:
      tags: [Invoices]
      summary: Create a draft copy with the same client + items
      description: Creates a new draft (no number until issued) with the same client snapshot, items, language, currency, and notes. Issue it with POST /invoices/{id}/issue.
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '201': { description: Created, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } } }

  /invoices/{id}/cancel:
    post:
      tags: [Invoices]
      summary: Set status to "cancelled"
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Invoice' } } } } } }

  /invoices/{id}/pdf:
    get:
      tags: [Invoices]
      summary: Download the invoice PDF (binary)
      description: |
        Returns `application/pdf` on success. Failures (missing invoice, render error,
        malformed output) return `application/json` with a `message` field — always check
        `Content-Type` before saving the body as a PDF.
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '200':
          description: PDF
          content:
            application/pdf: { schema: { type: string, format: binary } }
        '404':
          description: Invoice not found
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        '500':
          description: Failed to render the PDF (e.g. Browsershot error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  error: { type: string }

  /invoices/bulk-mark-paid:
    post:
      tags: [Invoices]
      summary: Record payments on up to 200 invoices in one call
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoices]
              properties:
                invoices:
                  type: array
                  maxItems: 200
                  items:
                    type: object
                    required: [id]
                    properties:
                      id: { type: integer }
                      amount: { type: number }
                      paid_at: { type: string, format: date }
      responses:
        '200':
          description: Per-id results
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        status: { $ref: '#/components/schemas/InvoiceStatus' }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        error: { type: string }

  /recurring-invoices:
    get:
      tags: [Recurring Invoices]
      summary: List recurring templates
      parameters:
        - { name: client_id, in: query, schema: { type: integer } }
        - { name: frequency, in: query, schema: { $ref: '#/components/schemas/RecurringFrequency' } }
        - { name: active_only, in: query, schema: { type: boolean } }
        - { name: search, in: query, schema: { type: string } }
        - { name: per_page, in: query, schema: { type: integer, default: 25 } }
        - { name: page, in: query, schema: { type: integer } }
      responses:
        '200':
          description: Paginated template list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/RecurringInvoice' } }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
    post:
      tags: [Recurring Invoices]
      summary: Create a recurring template
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecurringInvoiceInput' }
      responses:
        '201':
          description: Created
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/RecurringInvoice' } } } } }

  /recurring-invoices/{id}:
    parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
    get:
      tags: [Recurring Invoices]
      summary: Get a recurring template
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/RecurringInvoice' } } } } } }
    put:
      tags: [Recurring Invoices]
      summary: Update a recurring template (replaces items if included)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecurringInvoiceInput' }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/RecurringInvoice' } } } } } }
    delete:
      tags: [Recurring Invoices]
      summary: Delete a recurring template
      responses:
        '200': { description: Deleted }

  /recurring-invoices/{id}/toggle:
    post:
      tags: [Recurring Invoices]
      summary: Pause / resume (flips is_active)
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/RecurringInvoice' } } } } } }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhooks (without secret)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
    post:
      tags: [Webhooks]
      summary: Register a webhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { $ref: '#/components/schemas/WebhookEvent' }
                description: { type: string, maxLength: 255 }
      responses:
        '201':
          description: Created (secret returned ONCE)
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/Webhook' }
                  - { type: object, properties: { secret: { type: string } } }

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Remove a webhook
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '200': { description: Deleted }

  /orders:
    get:
      tags: [Orders]
      summary: List orders
      description: |
        Staff without the `orders.view_all` permission only see orders assigned to
        them or unassigned (`assigned_staff_id` is null) — the same scoping the web
        UI applies. These endpoints are for staff users; client-portal users are
        redirected away from all non-portal routes (including this one) and cannot
        reach it in the current build. Super admins are unaffected by the scoping
        (they bypass permission checks entirely).
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/OrderStatus' } }
        - { name: client_id, in: query, schema: { type: integer } }
        - { name: created_from, in: query, schema: { type: string, format: date } }
        - { name: created_to, in: query, schema: { type: string, format: date } }
        - { name: search, in: query, schema: { type: string }, description: Matches order_number/client company_name/client contact_name }
        - { name: per_page, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: page, in: query, schema: { type: integer } }
      responses:
        '200':
          description: Paginated order summary list (no items/status_history/invoices/delivery_notes — detail-only)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Order' } }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /orders/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Orders]
      summary: Get an order with items, status history, invoices, and delivery notes
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Order' } } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /orders/{id}/status:
    post:
      tags: [Orders]
      summary: Transition an order's status
      description: |
        Valid transitions: draft → pending, draft → cancelled, pending → completed,
        pending → cancelled, cancelled → pending. Completing an order (pending →
        completed) auto-creates its delivery note and emails it to the client,
        identical to the web UI — this happens asynchronously after the response
        is sent.
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: '#/components/schemas/OrderStatus' }
                comment: { type: string, maxLength: 1000 }
                cancellation_reason: { type: string, maxLength: 1000, description: 'Required when status is cancelled. Stored on the order and used as the history comment instead of comment.' }
      responses:
        '200':
          description: Updated order (same shape as GET /orders/{id})
          content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Order' } } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Illegal transition, or missing cancellation_reason when cancelling
          content:
            application/json:
              schema:
                type: object
                properties: { message: { type: string } }

  /dashboard:
    get:
      tags: [Dashboard]
      summary: Tenant summary counters and recent activity
      description: |
        `orders` is null without `orders.view_own`; `invoices` is null without
        `invoices.view`. 403 only if the caller has neither permission.
        `invoices.unpaid_total` counts partially-paid invoices at their full
        grand_total, not the remaining balance — a v1 approximation.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DashboardStats' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /devices:
    post:
      tags: [Devices]
      summary: Register a mobile push device token
      description: Idempotent by token — registering the same token again updates its owner/platform/tenant instead of duplicating.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DeviceRegistration' }
      responses:
        '201':
          description: Registered
          content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Devices]
      summary: Remove a device token (e.g. on logout)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, maxLength: 255 }
      responses:
        '200':
          description: Removed
          content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } }
        '422': { $ref: '#/components/responses/ValidationError' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum
    tenantHeader:
      type: apiKey
      in: header
      name: X-Tenant-Id

  responses:
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
              errors:
                type: object
                additionalProperties:
                  type: array
                  items: { type: string }
    NotFound:
      description: Not found (or belongs to a different tenant)
      content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } }
    Forbidden:
      description: Token valid but the user lacks the required permission
      content: { application/json: { schema: { type: object, properties: { message: { type: string } } } } }

  schemas:
    InvoiceType:
      type: string
      enum: [invoice, proforma, credit_note]
    InvoiceStatus:
      type: string
      enum: [draft, issued, sent, paid, partially_paid, cancelled, overdue]
    InvoiceStatusFilter:
      type: string
      description: 'Same values as InvoiceStatus PLUS the virtual "overdue" filter (unpaid + due_date < today).'
      enum: [draft, issued, sent, paid, partially_paid, cancelled, overdue]
    PaymentMethod:
      type: string
      enum: [transfer, cash, card, cod]
    Language:
      type: string
      description: 'Document language for PDF and email rendering.'
      enum: [sk, en, de, cs]
    RecurringFrequency:
      type: string
      enum: [none, weekly, 2_weeks, monthly, 2_months, 3_months, 4_months, 6_months, yearly, 2_years, 4_years]
    WebhookEvent:
      type: string
      enum: [invoice.created, invoice.sent, invoice.paid, client.created]

    PaginationMeta:
      type: object
      properties:
        current_page: { type: integer }
        last_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }

    Client:
      type: object
      properties:
        id: { type: integer }
        company_name: { type: string, nullable: true }
        contact_name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        zip: { type: string, nullable: true }
        city: { type: string, nullable: true }
        country: { type: string, nullable: true }
        ico: { type: string, nullable: true }
        dic: { type: string, nullable: true }
        ic_dph: { type: string, nullable: true }
        iban: { type: string, nullable: true }
        swift: { type: string, nullable: true }
        bank_account: { type: string, nullable: true }
        is_active: { type: boolean }
        language: { $ref: '#/components/schemas/Language' }
        tags: { type: array, items: { type: string } }
        created_at: { type: string, format: date-time }
    PaginatedClients:
      type: object
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Client' } }
        meta: { $ref: '#/components/schemas/PaginationMeta' }
    ClientInput:
      type: object
      properties:
        company_name: { type: string, description: 'Required if contact_name is empty' }
        contact_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        address: { type: string }
        zip: { type: string }
        city: { type: string }
        country: { type: string, example: Slovensko }
        ico: { type: string }
        dic: { type: string }
        ic_dph: { type: string, description: 'Country-prefixed VAT ID e.g. DE812236425' }
        iban: { type: string }
        swift: { type: string }
        bank_account: { type: string }
        language: { $ref: '#/components/schemas/Language' }
        tags: { type: array, items: { type: string, maxLength: 50 } }

    Product:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        sku: { type: string, nullable: true }
        unit: { type: string, nullable: true }
        unit_price: { type: number }
        tax_rate: { type: number }
        is_active: { type: boolean }

    InvoiceItem:
      type: object
      properties:
        id: { type: integer }
        product_id: { type: integer, nullable: true }
        name: { type: string }
        sku: { type: string, nullable: true }
        quantity: { type: number, nullable: true, description: 'When null, treated as 1 for math; column hidden on PDF if no item provides quantity.' }
        unit: { type: string, nullable: true }
        unit_price: { type: number }
        tax_rate: { type: number }
        tax_amount: { type: number }
        discount_amount: { type: number }
        line_total: { type: number, description: 'Stored INCLUDING tax (qty * price + tax - discount).' }
    InvoiceItemInput:
      type: object
      description: 'Provide either product_id (with optional overrides) OR all of name/unit_price/tax_rate.'
      properties:
        product_id: { type: integer }
        name: { type: string, maxLength: 255 }
        sku: { type: string }
        quantity: { type: number, description: 'Optional; null/0 ⇒ 1' }
        unit: { type: string, description: 'e.g. ks, hod., m, m2, kg, mes.' }
        unit_price: { type: number }
        tax_rate: { type: number, description: 'Percent. Common: 23, 19, 10, 5, 0' }
        discount_amount: { type: number }

    Invoice:
      type: object
      properties:
        id: { type: integer }
        type: { $ref: '#/components/schemas/InvoiceType' }
        invoice_number: { type: string, description: 'System-generated using tenant numbering format' }
        name: { type: string, nullable: true, description: 'Optional internal label, not on PDF' }
        status: { $ref: '#/components/schemas/InvoiceStatus' }
        is_overdue: { type: boolean }
        client_id: { type: integer }
        client_name: { type: string }
        client_ico: { type: string, nullable: true }
        client_email: { type: string, nullable: true }
        issue_date: { type: string, format: date }
        delivery_date: { type: string, format: date, nullable: true }
        due_date: { type: string, format: date }
        currency: { type: string }
        language: { $ref: '#/components/schemas/Language' }
        subtotal: { type: number }
        tax_total: { type: number }
        grand_total: { type: number }
        paid_amount: { type: number }
        remaining_amount: { type: number }
        paid_at: { type: string, format: date-time, nullable: true }
        variable_symbol: { type: string }
        constant_symbol: { type: string, nullable: true }
        specific_symbol: { type: string, nullable: true }
        payment_method: { $ref: '#/components/schemas/PaymentMethod' }
        notes: { type: string, nullable: true }
        internal_notes: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        order_ids: { type: array, items: { type: integer }, description: 'Orders billed by this invoice' }
        items: { type: array, items: { $ref: '#/components/schemas/InvoiceItem' } }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    InvoiceInput:
      type: object
      required: [client_id, items]
      properties:
        client_id: { type: integer }
        type: { $ref: '#/components/schemas/InvoiceType' }
        name: { type: string, description: 'Optional internal label' }
        issue_date: { type: string, format: date }
        delivery_date: { type: string, format: date }
        due_date: { type: string, format: date }
        currency: { type: string, example: EUR }
        language: { $ref: '#/components/schemas/Language' }
        payment_method: { $ref: '#/components/schemas/PaymentMethod' }
        status:
          type: string
          description: 'issued (default) or draft. A draft is created without a number and consumes no counter; the number is assigned when issued. Drafts cannot be sent or rendered to PDF.'
          enum: [draft, issued]
          default: issued
        notes: { type: string }
        description: { type: string }
        tags: { type: array, items: { type: string } }
        order_ids:
          type: array
          items: { type: integer }
          description: 'Optional: link the invoice to the client''s orders. Each order must belong to the client and not be linked to another invoice (422 otherwise). Lines still come from `items`.'
        items:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/InvoiceItemInput' }

    RecurringInvoiceItem:
      type: object
      properties:
        id: { type: integer }
        product_id: { type: integer, nullable: true }
        name: { type: string }
        sku: { type: string, nullable: true }
        quantity: { type: number, nullable: true }
        unit: { type: string, nullable: true }
        unit_price: { type: number }
        tax_rate: { type: number }
        discount_percent: { type: number }
    RecurringInvoiceItemInput:
      type: object
      required: [name, unit_price]
      properties:
        name: { type: string }
        sku: { type: string }
        quantity: { type: number }
        unit: { type: string }
        unit_price: { type: number }
        tax_rate: { type: number }
        discount_percent: { type: number }

    RecurringInvoice:
      type: object
      properties:
        id: { type: integer }
        external_id: { type: string, nullable: true, description: 'Used for idempotent imports (e.g. sf:1234)' }
        name: { type: string, nullable: true }
        client_id: { type: integer }
        client_name: { type: string }
        frequency: { $ref: '#/components/schemas/RecurringFrequency' }
        frequency_label: { type: string }
        start_date: { type: string, format: date }
        next_issue_date: { type: string, format: date, nullable: true }
        end_date: { type: string, format: date, nullable: true }
        day_of_month: { type: integer, nullable: true }
        due_days: { type: integer }
        payment_method: { $ref: '#/components/schemas/PaymentMethod' }
        currency: { type: string }
        auto_issue: { type: boolean }
        auto_send: { type: boolean }
        send_to_emails: { type: string, nullable: true, description: 'Comma- or semicolon-separated' }
        is_active: { type: boolean }
        description: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        invoices_count: { type: integer }
        last_issued_at: { type: string, format: date-time, nullable: true }
        grand_total: { type: number }
        items: { type: array, items: { $ref: '#/components/schemas/RecurringInvoiceItem' } }
    RecurringInvoiceInput:
      type: object
      required: [client_id, frequency, start_date, items]
      properties:
        client_id: { type: integer }
        name: { type: string }
        frequency: { $ref: '#/components/schemas/RecurringFrequency' }
        start_date: { type: string, format: date }
        next_issue_date: { type: string, format: date }
        end_date: { type: string, format: date }
        day_of_month: { type: integer, minimum: 1, maximum: 31 }
        due_days: { type: integer, minimum: 0, maximum: 365, default: 14 }
        payment_method: { $ref: '#/components/schemas/PaymentMethod' }
        currency: { type: string }
        description: { type: string }
        notes: { type: string }
        auto_issue: { type: boolean }
        auto_send: { type: boolean }
        send_to_emails: { type: string }
        is_active: { type: boolean }
        items:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/RecurringInvoiceItemInput' }

    Webhook:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEvent' } }
        description: { type: string, nullable: true }
        is_active: { type: boolean }
        last_delivered_at: { type: string, format: date-time, nullable: true }
        failure_count: { type: integer }
        last_error: { type: string, nullable: true }

    OrderStatus:
      type: string
      enum: [draft, pending, completed, cancelled]
    OrderPriority:
      type: string
      enum: [low, normal, high, urgent]

    OrderItem:
      type: object
      properties:
        id: { type: integer }
        product_id: { type: integer, nullable: true }
        product_name: { type: string }
        product_sku: { type: string, nullable: true }
        quantity: { type: number }
        unit: { type: string, nullable: true }
        unit_price: { type: number }
        tax_rate: { type: number }
        tax_amount: { type: number }
        discount_amount: { type: number }
        line_total: { type: number, description: 'GROSS: net + VAT - discount.' }
        notes: { type: string, nullable: true }

    OrderStatusHistoryEntry:
      type: object
      properties:
        from_status: { $ref: '#/components/schemas/OrderStatus' }
        to_status: { $ref: '#/components/schemas/OrderStatus' }
        comment: { type: string, nullable: true }
        changed_by_user_id: { type: integer }
        created_at: { type: string, format: date-time }

    Order:
      type: object
      description: |
        List responses (GET /orders) include only the base fields below.
        Detail responses (GET /orders/{id}, POST /orders/{id}/status) additionally
        include notes, client_notes, confirmed_date, completed_at, cancelled_at,
        cancellation_reason, subtotal, discount_total, items, status_history,
        invoices, delivery_notes, and allowed_transitions.
      properties:
        id: { type: integer }
        order_number: { type: string }
        status: { $ref: '#/components/schemas/OrderStatus' }
        priority: { $ref: '#/components/schemas/OrderPriority' }
        currency: { type: string }
        requested_date: { type: string, format: date, nullable: true }
        grand_total: { type: number, description: 'GROSS (VAT included).' }
        is_billed: { type: boolean, description: 'True if at least one invoice is linked to this order.' }
        created_at: { type: string, format: date-time }
        client:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            company_name: { type: string, nullable: true }
            contact_name: { type: string, nullable: true }
        assigned_staff:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            name: { type: string }
        items_count: { type: integer, description: 'List responses only.' }
        notes: { type: string, nullable: true, description: 'Detail responses only.' }
        client_notes: { type: string, nullable: true, description: 'Detail responses only.' }
        confirmed_date: { type: string, format: date, nullable: true, description: 'Detail responses only.' }
        completed_at: { type: string, format: date-time, nullable: true, description: 'Detail responses only.' }
        cancelled_at: { type: string, format: date-time, nullable: true, description: 'Detail responses only.' }
        cancellation_reason: { type: string, nullable: true, description: 'Detail responses only.' }
        subtotal: { type: number, description: 'Detail responses only. GROSS, same convention as items[].line_total.' }
        discount_total: { type: number, description: 'Detail responses only.' }
        items: { type: array, items: { $ref: '#/components/schemas/OrderItem' }, description: 'Detail responses only.' }
        status_history: { type: array, items: { $ref: '#/components/schemas/OrderStatusHistoryEntry' }, description: 'Detail responses only.' }
        invoices:
          type: array
          description: 'Detail responses only.'
          items:
            type: object
            properties:
              id: { type: integer }
              invoice_number: { type: string, nullable: true }
              status: { $ref: '#/components/schemas/InvoiceStatus' }
        delivery_notes:
          type: array
          description: 'Detail responses only.'
          items:
            type: object
            properties:
              id: { type: integer }
              delivery_note_number: { type: string, nullable: true }
        allowed_transitions:
          type: array
          description: 'Detail responses only. Statuses this order can move to from its current status, already filtered for client vs. staff/agent.'
          items: { $ref: '#/components/schemas/OrderStatus' }

    DashboardStats:
      type: object
      properties:
        orders:
          type: object
          nullable: true
          description: 'Null without the orders.view_own permission.'
          properties:
            today: { type: integer, description: 'Orders created today.' }
            pending: { type: integer }
        invoices:
          type: object
          nullable: true
          description: 'Null without the invoices.view permission.'
          properties:
            unpaid_count: { type: integer }
            unpaid_total: { type: number, description: 'Partially-paid invoices are counted at their full grand_total, not the remaining balance (v1 approximation).' }
            overdue_count: { type: integer }
            overdue_total: { type: number }
        recent_orders:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              order_number: { type: string }
              status: { $ref: '#/components/schemas/OrderStatus' }
              grand_total: { type: number }
              client_name: { type: string, nullable: true }
              created_at: { type: string, format: date-time }
        recent_invoices:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              invoice_number: { type: string, nullable: true }
              status: { $ref: '#/components/schemas/InvoiceStatus' }
              grand_total: { type: number }
              client_name: { type: string, nullable: true }
              due_date: { type: string, format: date, nullable: true }

    DeviceRegistration:
      type: object
      required: [token, platform]
      properties:
        token: { type: string, maxLength: 255, description: 'FCM device token.' }
        platform: { type: string, enum: [ios, android] }
