openapi: 3.1.0
info:
  title: Billey API
  version: 1.0.0
  description: >-
    The Billey REST API (`/api/v1`) gives an integration read and write access
    to the accounting data of a single administration (book).


    ## Authentication


    Every request is authenticated with a personal access token sent as an HTTP
    bearer token: `Authorization: Bearer <token>`. A token is bound to exactly
    ONE administration — there is no `{book}` segment in any URL, and the token
    itself decides which book you are working in. A token also carries a frozen
    set of abilities (e.g. `invoices:read`, `clients:write`); each endpoint
    additionally re-checks, on every request, that the account still holds the
    matching live permission.


    ## Conventions


    - All responses are JSON. A single resource is wrapped as `{ "data": { … } }`;
      a list is wrapped as `{ "data": [ … ], "links": { … }, "meta": { … } }`.

    - Money is serialised as a fixed 2-decimal string (e.g. `"1250.00"`), never a
      number; `null` means "no amount", which is distinct from `"0.00"`.
      Decimal-cast fields (quantity, tax rates, hours) are strings too.

    - Timestamps are UTC ISO-8601 with a `Z` suffix (`2026-07-21T10:15:30Z`);
      date-only fields are plain `Y-m-d`.

    - Lists use cursor pagination and support incremental sync (`updated_after`)
      and sparse fieldsets (`fields`).

    - All create (POST) operations require an `Idempotency-Key` header so retries
      are safe.

    - Rate limited to 60 requests/minute per token.
  contact:
    email: support@billey.nl

servers:
  - url: https://billey.nl
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Clients
    description: Customers and members you invoice.
  - name: Vendors
    description: Suppliers you record expenses against.
  - name: Invoices
    description: Outgoing invoices and credit notes, with their line items and lifecycle transitions.
  - name: Quotes
    description: >-
      Quotations, with line items and a full CRUD write surface mirroring
      invoices (create, full-replace update, hard delete), plus lifecycle
      transitions and convert-to-invoice. Ride the `quotes:read` /
      `quotes:write` abilities.
  - name: Expenses
    description: Recorded costs and purchase invoices.
  - name: Documents
    description: Inbound source files (receipts, purchase invoices) received by email or upload. Ride the `expenses:read` ability.
  - name: Bank transactions
    description: Imported bank transactions (read-only).
  - name: Ledger
    description: Double-entry ledger journal legs (read-only). Ride the `reports:read` ability.
  - name: Products
    description: Products, services and hourly rates (read-only).
  - name: Projects
    description: Projects (read-only). Gated by the per-book `projects_enabled` toggle.
  - name: Time entries
    description: >-
      Tracked time. Carries its own `time-entries:read` / `time-entries:write`
      abilities — these used to ride `projects:read`, which no longer covers them.
  - name: Webhooks
    description: Events Billey delivers to your endpoint.

paths:

  # ------------------------------------------------------------------ Clients

  /api/v1/clients:
    get:
      tags: [Clients]
      summary: List clients
      operationId: listClients
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IncludeDeleted'
      responses:
        '200':
          description: A page of clients.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Client' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Clients]
      summary: Create a client
      operationId: createClient
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientCreate' }
      responses:
        '201':
          description: The client was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '200':
          description: Idempotency-Key replay — the original client is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/clients/{client}:
    parameters:
      - name: client
        in: path
        required: true
        description: Client id.
        schema: { type: integer }
    get:
      tags: [Clients]
      summary: Get a client
      operationId: getClient
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Clients]
      summary: Update a client
      operationId: updateClient
      description: >-
        Partial update — only the keys present in the payload are changed.
        Note the direct-debit cross-field rule: touching any of
        `collection_method`, `iban`, or `mandate_date` validates the RESULTING
        record state, so switching `collection_method` to `direct_debit`
        without an IBAN already on file (and without sending one) fails.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientUpdate' }
      responses:
        '200':
          description: The updated client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Clients]
      summary: Delete a client
      operationId: deleteClient
      description: >-
        Soft-deletes the client. Refused with 409 if the client has accounting
        history (a non-draft invoice) or still-live references (quotes,
        recurring schedules); the client's own draft invoices cascade.
      responses:
        '204':
          description: The client was deleted.
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Vendors

  /api/v1/vendors:
    get:
      tags: [Vendors]
      summary: List vendors
      operationId: listVendors
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IncludeDeleted'
      responses:
        '200':
          description: A page of vendors.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Vendor' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Vendors]
      summary: Create a vendor
      operationId: createVendor
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VendorCreate' }
      responses:
        '201':
          description: The vendor was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Vendor' }
        '200':
          description: Idempotency-Key replay — the original vendor is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Vendor' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/vendors/{vendor}:
    parameters:
      - name: vendor
        in: path
        required: true
        description: Vendor id.
        schema: { type: integer }
    get:
      tags: [Vendors]
      summary: Get a vendor
      operationId: getVendor
      responses:
        '200':
          description: The vendor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Vendor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Vendors]
      summary: Update a vendor
      operationId: updateVendor
      description: Partial update — only the keys present in the payload are changed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VendorUpdate' }
      responses:
        '200':
          description: The updated vendor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Vendor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Vendors]
      summary: Delete a vendor
      operationId: deleteVendor
      description: >-
        Soft-deletes the vendor. Refused with 409 if the vendor has a non-draft
        or asset-linked expense, a bank transaction, or a booking rule; the
        vendor's own draft expenses cascade.
      responses:
        '204':
          description: The vendor was deleted.
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Invoices

  /api/v1/invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      operationId: listInvoices
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IncludeDeleted'
      responses:
        '200':
          description: A page of invoices (each with its embedded client summary and line items).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Invoices]
      summary: Create an invoice
      operationId: createInvoice
      description: >-
        Creates a draft invoice. `invoice_number` is optional — when omitted the
        next number is allocated automatically. `items` must contain at least
        one line. The optional `time_entry_ids` claims uninvoiced time entries
        onto the new invoice.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceCreate' }
      responses:
        '201':
          description: The invoice was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '200':
          description: Idempotency-Key replay — the original invoice is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    get:
      tags: [Invoices]
      summary: Get an invoice
      operationId: getInvoice
      responses:
        '200':
          description: The invoice, with its embedded client summary and line items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Invoices]
      summary: Replace an invoice
      operationId: updateInvoice
      description: >-
        FULL REPLACE, not a partial merge — the entire invoice is rewritten from
        the payload. You must resend every header field and the COMPLETE `items`
        array (line items are deleted and rewritten). An omitted `notes` becomes
        null; an omitted `reverse_charge_applied` becomes false. `invoice_number`
        may be omitted to keep the current number. Only a draft invoice can be
        edited (409 otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceUpdate' }
      responses:
        '200':
          description: The replaced invoice.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Invoices]
      summary: Delete an invoice
      operationId: deleteInvoice
      description: Soft-deletes the invoice. Only a draft invoice can be deleted (409 otherwise).
      responses:
        '204':
          description: The invoice was deleted.
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/pdf:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    get:
      tags: [Invoices]
      summary: Download an invoice PDF
      operationId: downloadInvoicePdf
      description: >-
        Renders and returns the same PDF the web app's "Download PDF" button
        produces — same branding, template, and payment QR code. Subject to a
        separate, tighter rate limit than the rest of the API (see
        [Rate limits](/docs/rate-limits)): PDF rendering launches headless
        Chrome per request.
      responses:
        '200':
          description: The rendered invoice PDF.
          headers:
            Content-Disposition:
              description: Inline disposition carrying the invoice number as the filename.
              schema: { type: string }
          content:
            application/pdf:
              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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/credit-note:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Id of the sent invoice to credit.
        schema: { type: integer }
    post:
      tags: [Invoices]
      summary: Create a credit note for an invoice
      operationId: createInvoiceCreditNote
      description: >-
        Creates a credit note against the given invoice. The source invoice must
        be Sent and must not itself be a credit note (409 otherwise). No request
        body. Credit-note totals are stored positive; direction is carried by
        `is_credit_note`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: The credit note was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '200':
          description: Idempotency-Key replay — the original credit note is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/send:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    post:
      tags: [Invoices]
      summary: Email an invoice and mark it sent
      operationId: sendInvoice
      description: >-
        Emails the invoice to the client and transitions it Draft -> Sent. The
        invoice must be a draft (409) and its client must have an email address
        (422). No request body.
      responses:
        '200':
          description: The invoice was emailed and marked sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/mark-sent:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    post:
      tags: [Invoices]
      summary: Mark an invoice sent without emailing
      operationId: markInvoiceSent
      description: >-
        Transitions the invoice Draft -> Sent WITHOUT sending an email — for
        integrations that deliver invoices through their own channel. The
        invoice must be a draft (409). No request body.
      responses:
        '200':
          description: The invoice was marked sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/mark-paid:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    post:
      tags: [Invoices]
      summary: Mark an invoice paid
      operationId: markInvoicePaid
      description: >-
        Transitions the invoice Sent -> Paid. The invoice must be Sent (409). No
        request body.
      responses:
        '200':
          description: The invoice was marked paid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/invoices/{invoice}/revert-to-draft:
    parameters:
      - name: invoice
        in: path
        required: true
        description: Invoice id.
        schema: { type: integer }
    post:
      tags: [Invoices]
      summary: Revert a sent invoice to draft
      operationId: revertInvoiceToDraft
      description: >-
        Transitions the invoice Sent -> Draft and clears `sent_at`. The invoice
        must be Sent — a paid invoice cannot be reverted (409), nor can one in a
        locked period (409). No request body.
      responses:
        '200':
          description: The invoice was reverted to draft.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Quotes

  /api/v1/quotes:
    get:
      tags: [Quotes]
      summary: List quotes
      operationId: listQuotes
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of quotes (each with its embedded client summary and line items).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Quote' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Quotes]
      summary: Create a quote
      operationId: createQuote
      description: >-
        Creates a draft quote. `quote_number` is optional — when omitted the
        next number is allocated automatically. `items` must contain at least
        one line.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteCreate' }
      responses:
        '201':
          description: The quote was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '200':
          description: Idempotency-Key replay — the original quote is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    get:
      tags: [Quotes]
      summary: Get a quote
      operationId: getQuote
      responses:
        '200':
          description: The quote, with its embedded client summary and line items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Quotes]
      summary: Replace a quote
      operationId: updateQuote
      description: >-
        FULL REPLACE, not a partial merge — the entire quote is rewritten from
        the payload. You must resend every header field and the COMPLETE
        `items` array (line items are deleted and rewritten). An omitted
        `notes` becomes null; an omitted `reverse_charge_applied` becomes
        false. `quote_number` cannot be changed via PATCH — unlike an
        invoice's `invoice_number`, it is fixed at creation and any
        `quote_number` sent here is ignored. Only a draft quote can be
        edited (409 otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteUpdate' }
      responses:
        '200':
          description: The replaced quote.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Quotes]
      summary: Delete a quote
      operationId: deleteQuote
      description: >-
        Hard-deletes the quote — Quote does not soft-delete. Unlike deleting
        an invoice, there is NO status restriction: a quote in any status,
        including one already converted to an invoice, can be deleted (the
        converted invoice itself is left untouched).
      responses:
        '204':
          description: The quote was deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/pdf:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    get:
      tags: [Quotes]
      summary: Download a quote PDF
      operationId: downloadQuotePdf
      description: >-
        Renders and returns the same PDF the web app's "Download PDF" button
        produces. Subject to a separate, tighter rate limit than the rest of
        the API (see [Rate limits](/docs/rate-limits)): PDF rendering
        launches headless Chrome per request.
      responses:
        '200':
          description: The rendered quote PDF.
          headers:
            Content-Disposition:
              description: Inline disposition carrying the quote number as the filename.
              schema: { type: string }
          content:
            application/pdf:
              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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/send:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    post:
      tags: [Quotes]
      summary: Email a quote and mark it sent
      operationId: sendQuote
      description: >-
        Emails the quote to the client and transitions it Draft -> Sent. The
        quote must be a draft (409) and its client must have an email address
        (422). No request body.
      responses:
        '200':
          description: The quote was emailed and marked sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/mark-sent:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    post:
      tags: [Quotes]
      summary: Mark a quote sent without emailing
      operationId: markQuoteSent
      description: >-
        Transitions the quote Draft -> Sent WITHOUT sending an email — for
        integrations that deliver quotes through their own channel. The
        quote must be a draft (409). No request body.
      responses:
        '200':
          description: The quote was marked sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/mark-accepted:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    post:
      tags: [Quotes]
      summary: Mark a quote accepted
      operationId: markQuoteAccepted
      description: >-
        Transitions the quote Sent -> Accepted. The quote must be Sent (409).
        No request body.
      responses:
        '200':
          description: The quote was marked accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/mark-declined:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    post:
      tags: [Quotes]
      summary: Mark a quote declined
      operationId: markQuoteDeclined
      description: >-
        Transitions the quote Sent -> Declined. The quote must be Sent
        (409). No request body.
      responses:
        '200':
          description: The quote was marked declined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Quote' }
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/quotes/{quote}/convert-to-invoice:
    parameters:
      - name: quote
        in: path
        required: true
        description: Quote id.
        schema: { type: integer }
    post:
      tags: [Quotes]
      summary: Convert a quote into an invoice
      operationId: convertQuoteToInvoice
      description: >-
        Creates a new Draft invoice from the quote's header and line items
        (the same writer `POST /api/v1/invoices` uses) and links it back via
        `converted_invoice_id`, setting the quote's status to Accepted. A
        quote converts ONCE: calling this again on an already-converted
        quote does not create a second invoice — it returns the EXISTING
        one with `200` instead of `201`, even under a different
        `Idempotency-Key` than the original call. No request body.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: A new invoice was created from the quote.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '200':
          description: >-
            The quote was already converted — either an Idempotency-Key
            replay of the original call, or a repeat call under a different
            key. The EXISTING invoice is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Expenses

  /api/v1/expenses:
    get:
      tags: [Expenses]
      summary: List expenses
      operationId: listExpenses
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/IncludeDeleted'
      responses:
        '200':
          description: A page of expenses (each with its embedded vendor summary and account).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Expense' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Expenses]
      summary: Create an expense
      operationId: createExpense
      description: >-
        Creates a Draft expense (any `status` in the payload is ignored — follow
        up with a PATCH to change status).


        Send `application/json` for fields only, or `multipart/form-data` to
        attach a source document in the same call. There are two mutually
        exclusive ways to attach one: `document_id` links an existing (e.g.
        email-ingested) document, while `file` uploads a new one (stored as a
        first-class document, downloadable via `GET
        /api/v1/documents/{document}/download`). Sending both is a 422. Both are
        accepted only on create.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpenseCreate' }
          multipart/form-data:
            schema: { $ref: '#/components/schemas/ExpenseUpload' }
            encoding:
              file:
                contentType: application/pdf, image/jpeg, image/png, image/webp, image/heic, image/tiff, image/bmp, application/xml
      responses:
        '201':
          description: The expense was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '200':
          description: Idempotency-Key replay — the original expense is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/expenses/{expense}:
    parameters:
      - name: expense
        in: path
        required: true
        description: Expense id.
        schema: { type: integer }
    get:
      tags: [Expenses]
      summary: Get an expense
      operationId: getExpense
      responses:
        '200':
          description: The expense, with its embedded vendor summary and account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Expenses]
      summary: Update an expense
      operationId: updateExpense
      description: >-
        Partial update — only the keys present in the payload are changed. Unlike
        the invoice PATCH, this is a genuine partial merge. Status transitions
        (Draft -> Pending -> Paid) ride the `status` field here (it is NOT
        accepted on create). Touching either of `date` / `due_date` validates the
        pair against the resulting record state.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpenseUpdate' }
      responses:
        '200':
          description: The updated expense.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Expenses]
      summary: Delete an expense
      operationId: deleteExpense
      description: >-
        Soft-deletes the expense and cleans up an orphaned source document.
        Refused with 409 for a period-locked or asset-linked expense.
      responses:
        '204':
          description: The expense was deleted.
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Documents

  /api/v1/documents:
    get:
      tags: [Documents]
      summary: List documents
      operationId: listDocuments
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of documents.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Document' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/documents/{document}:
    parameters:
      - name: document
        in: path
        required: true
        description: Document id.
        schema: { type: integer }
    get:
      tags: [Documents]
      summary: Get a document
      operationId: getDocument
      responses:
        '200':
          description: The document metadata. The file bytes are fetched separately via the download endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/documents/{document}/download:
    parameters:
      - name: document
        in: path
        required: true
        description: Document id.
        schema: { type: integer }
    get:
      tags: [Documents]
      summary: Download a document file
      operationId: downloadDocument
      description: Streams the raw stored document file as a binary attachment.
      responses:
        '200':
          description: The raw document file.
          headers:
            Content-Disposition:
              description: Attachment disposition carrying the original filename.
              schema: { type: string }
          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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------ Bank transactions

  /api/v1/bank-transactions:
    get:
      tags: [Bank transactions]
      summary: List bank transactions
      operationId: listBankTransactions
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of bank transactions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/BankTransaction' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/bank-transactions/{bankTransaction}:
    parameters:
      - name: bankTransaction
        in: path
        required: true
        description: Bank transaction id.
        schema: { type: integer }
    get:
      tags: [Bank transactions]
      summary: Get a bank transaction
      operationId: getBankTransaction
      responses:
        '200':
          description: The bank transaction.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BankTransaction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Ledger

  /api/v1/ledger-entries:
    get:
      tags: [Ledger]
      summary: List ledger entries
      operationId: listLedgerEntries
      description: >-
        Flat double-entry journal legs. Each posting writes two or more balanced
        rows sharing `journalable_type` + `journalable_id` + `type` + `date`;
        group on that tuple to reassemble a journal. Exactly one of
        `debit` / `credit` is set per row.
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of ledger entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LedgerEntry' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/ledger-entries/{ledgerEntry}:
    parameters:
      - name: ledgerEntry
        in: path
        required: true
        description: Ledger entry id.
        schema: { type: integer }
    get:
      tags: [Ledger]
      summary: Get a ledger entry
      operationId: getLedgerEntry
      responses:
        '200':
          description: The ledger entry.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/LedgerEntry' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Products

  /api/v1/products:
    get:
      tags: [Products]
      summary: List products
      operationId: listProducts
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of products.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/products/{product}:
    parameters:
      - name: product
        in: path
        required: true
        description: Product id.
        schema: { type: integer }
    get:
      tags: [Products]
      summary: Get a product
      operationId: getProduct
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Projects

  /api/v1/projects:
    get:
      tags: [Projects]
      summary: List projects
      operationId: listProjects
      description: Gated by the per-book `projects_enabled` toggle; a toggle-off book returns 404.
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of projects (each with its embedded client summary).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Project' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Projects]
      summary: Create a project
      operationId: createProject
      description: >-
        Gated by the per-book `projects_enabled` toggle; a toggle-off book returns
        404, before validation. A new project is always created `active` — any
        `status` in the payload is ignored; PATCH it afterwards to change it.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProjectCreate' }
      responses:
        '201':
          description: The project was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Project' }
        '200':
          description: Idempotency-Key replay — the original project is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Project' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/projects/{project}:
    parameters:
      - name: project
        in: path
        required: true
        description: Project id.
        schema: { type: integer }
    get:
      tags: [Projects]
      summary: Get a project
      operationId: getProject
      description: Gated by the per-book `projects_enabled` toggle; a toggle-off book returns 404.
      responses:
        '200':
          description: The project, with its embedded client summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Projects]
      summary: Update a project
      operationId: updateProject
      description: >-
        Partial update — only the keys present in the payload are changed. This
        is also where status transitions live; there are no separate transition
        endpoints. Gated by the per-book `projects_enabled` toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProjectUpdate' }
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Projects]
      summary: Delete a project
      operationId: deleteProject
      description: >-
        Always succeeds with 204; what happens underneath depends on whether the
        project has ever been invoiced.


        A project with NO invoiced time entries is deleted permanently, and its
        uninvoiced time entries are deleted with it.


        A project that HAS invoiced time entries is retained internally so its
        invoices can still say which project they billed — invoices carry no
        project reference of their own, and reach it only through these entries.
        It disappears from every endpoint immediately: this GET, the list, and the
        project's time entries all 404 or omit it afterwards. There is no
        undelete. Archive the project instead if you want it to stay visible.


        Gated by the per-book `projects_enabled` toggle.
      responses:
        '204':
          description: The project was deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------------ Time entries

  /api/v1/time-entries:
    get:
      tags: [Time entries]
      summary: List time entries
      operationId: listTimeEntries
      description: Gated by the per-book `projects_enabled` toggle; a toggle-off book returns 404.
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/UpdatedAfter'
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of time entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TimeEntry' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Time entries]
      summary: Log a time entry
      operationId: createTimeEntry
      description: >-
        Logs hours against a project. Gated by the per-book `projects_enabled`
        toggle; a toggle-off book returns 404, before validation.


        `project_id` must belong to your book and must not be a deleted project.
        `invoice_id` is not accepted here or on PATCH — hours are billed by
        creating an invoice from the project, never by pointing an entry at one.


        The `Idempotency-Key` is unique per PROJECT rather than per book, because
        a time entry is scoped to its project. Reusing one key across two
        different projects therefore creates two entries; reusing it on the same
        project replays the first.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TimeEntryCreate' }
      responses:
        '201':
          description: The time entry was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TimeEntry' }
        '200':
          description: Idempotency-Key replay — the original time entry is returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TimeEntry' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/time-entries/{timeEntry}:
    parameters:
      - name: timeEntry
        in: path
        required: true
        description: Time entry id.
        schema: { type: integer }
    get:
      tags: [Time entries]
      summary: Get a time entry
      operationId: getTimeEntry
      description: Gated by the per-book `projects_enabled` toggle; a toggle-off book returns 404.
      responses:
        '200':
          description: The time entry.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TimeEntry' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Time entries]
      summary: Update a time entry
      operationId: updateTimeEntry
      description: >-
        Partial update — only the keys present in the payload are changed.


        Refused with 409 while the entry is invoiced (`is_invoiced` true). Billed
        hours are frozen so they cannot drift out of agreement with the invoice
        line they were billed on. Issue a credit note and re-invoice to correct
        billed work. The freeze lifts if the claiming invoice is deleted — only a
        draft invoice can be deleted, so its hours were never sent.


        `project_id` may be changed to move the entry to another project in the
        same book. `invoice_id` is not accepted. Gated by the per-book
        `projects_enabled` toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TimeEntryUpdate' }
      responses:
        '200':
          description: The updated time entry.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TimeEntry' }
        '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/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Time entries]
      summary: Delete a time entry
      operationId: deleteTimeEntry
      description: >-
        PERMANENTLY deletes the time entry — time entries are not soft-deleted.


        Refused with 409 once the entry has been invoiced (`is_invoiced` true):
        the per-day detail behind a billed invoice line lives only here and could
        not be recovered. Gated by the per-book `projects_enabled` toggle.
      responses:
        '204':
          description: The time entry was deleted.
        '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' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

# ============================================================================ Webhooks

webhooks:

  invoice.created:
    post:
      tags: [Webhooks]
      summary: invoice.created
      operationId: webhookInvoiceCreated
      description: >-
        Delivered when an invoice is created. See the `WebhookEnvelope` schema
        and the shared delivery notes below.


        Delivery headers: `X-Billey-Signature: v1=<hex hmac-sha256>` (over
        `"{timestamp}.{rawBody}"`), `X-Billey-Timestamp`, `X-Billey-Event-Id`
        (matches the body `id`), `X-Billey-Event-Type` (unsigned mirror of the
        body `type`). Verify against the raw body with a constant-time compare;
        the signed body always wins over the convenience headers.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceCreatedEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

  invoice.sent:
    post:
      tags: [Webhooks]
      summary: invoice.sent
      operationId: webhookInvoiceSent
      description: Delivered when an invoice transitions to Sent. Same delivery headers and verification as `invoice.created`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceSentEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

  invoice.paid:
    post:
      tags: [Webhooks]
      summary: invoice.paid
      operationId: webhookInvoicePaid
      description: Delivered when an invoice transitions to Paid. Same delivery headers and verification as `invoice.created`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoicePaidEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

  expense.created:
    post:
      tags: [Webhooks]
      summary: expense.created
      operationId: webhookExpenseCreated
      description: Delivered when an expense is created. Same delivery headers and verification as `invoice.created`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpenseCreatedEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

  expense.paid:
    post:
      tags: [Webhooks]
      summary: expense.paid
      operationId: webhookExpensePaid
      description: Delivered when an expense transitions to Paid. Same delivery headers and verification as `invoice.created`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpensePaidEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

  client.created:
    post:
      tags: [Webhooks]
      summary: client.created
      operationId: webhookClientCreated
      description: Delivered when a client is created. Same delivery headers and verification as `invoice.created`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientCreatedEvent' }
      responses:
        '200':
          description: Return any 2xx to acknowledge receipt.

# ============================================================================ Components

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        A Billey personal access token. One token is bound to exactly one
        administration (book) and carries a fixed set of abilities.

  parameters:

    PerPage:
      name: per_page
      in: query
      required: false
      description: Rows per page (cursor pagination). 1-100, default 25.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25

    UpdatedAfter:
      name: updated_after
      in: query
      required: false
      description: >-
        Return only rows whose `updated_at` is strictly greater than this
        timestamp — for incremental sync. Strict `>`: passing the newest
        `updated_at` you already ingested never re-returns that boundary row.
      schema:
        type: string
        format: date-time

    Fields:
      name: fields
      in: query
      required: false
      description: >-
        Sparse fieldset — a comma-separated allowlist of top-level keys to
        return. `id` is always included. Unknown names are ignored; a blank or
        absent value returns every field. A nested resource selected by this
        filter is returned in full.
      schema:
        type: string
      example: id,name,email

    Cursor:
      name: cursor
      in: query
      required: false
      description: >-
        Opaque pagination cursor taken from `links.next` / `links.prev`. A
        malformed cursor degrades to the first page rather than erroring.
      schema:
        type: string

    IncludeDeleted:
      name: include_deleted
      in: query
      required: false
      description: >-
        Set to `1`/`true` to include soft-deleted rows in the list (accepts
        0/1/true/false). The single-resource GET always 404s a soft-deleted row
        regardless.
      schema:
        type: string
        enum: ['0', '1', 'true', 'false']

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        Required on every create (POST). A unique key (e.g. a UUID) identifying
        this create so retries are safe: the first request with a given key
        creates the resource (201); any replay with the same key returns the
        original resource (200) and creates nothing. Must be non-empty, valid
        UTF-8, free of control characters, and at most 255 bytes — otherwise 400.
      schema:
        type: string
        maxLength: 255

  responses:

    BadRequest:
      description: Malformed request — e.g. a missing, empty, oversized, or non-UTF-8 Idempotency-Key header.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: The Idempotency-Key header is required.

    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: Unauthenticated.

    PaymentRequired:
      description: The administration this token belongs to has no active subscription.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: This administration has no active subscription.

    Forbidden:
      description: >-
        The token lacks the required ability, the account no longer holds the
        mapped permission, or access to the administration was revoked.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ForbiddenError' }
          example:
            message: "This token does not have the 'invoices:write' ability."
            required_ability: invoices:write

    NotFound:
      description: >-
        No such resource in this administration (also returned for a soft-deleted
        row on a single-resource GET, and for a feature-gated surface on a book
        with the toggle off). The body is deliberately blank.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: ""

    Conflict:
      description: >-
        The request conflicts with the resource's current state — a locked
        accounting period, an invalid lifecycle transition, or a still-referenced
        record on delete.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: This invoice can no longer be edited because it is not a draft.

    UnprocessableEntity:
      description: Validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
          example:
            message: The name field is required.
            errors:
              name:
                - The name field is required.

    TooManyRequests:
      description: Rate limit exceeded (60 requests/minute per token).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: Too Many Attempts.

  schemas:

    # -------------------------------------------------------------- Primitives

    Money:
      type: [string, "null"]
      pattern: '^-?\d+\.\d{2}$'
      description: >-
        A monetary amount as a fixed 2-decimal string (never a number). `null`
        means "no amount", which is distinct from `"0.00"`. Invoice/quote totals
        are always positive (direction is carried by `is_credit_note`); a bank
        transaction amount is signed.
      example: "1250.00"

    Decimal:
      type: [string, "null"]
      description: >-
        A decimal-cast value rendered as a string (e.g. a quantity, tax rate, or
        hours). Not a monetary amount.
      example: "21.00"

    Timestamp:
      type: [string, "null"]
      format: date-time
      description: UTC ISO-8601 timestamp with a `Z` suffix, second precision.
      example: "2026-07-21T10:15:30Z"

    DateOnly:
      type: [string, "null"]
      format: date
      description: A calendar date with no time component (`Y-m-d`).
      example: "2026-07-21"

    MoneyWrite:
      type: string
      pattern: '^-?\d+(\.\d+)?$'
      description: >-
        A monetary amount written as a plain dot-decimal string (no thousands
        separator, no comma, no exponent). A native JSON number is also accepted.
        Absolute ceiling 99999999.99.
      example: "1250.00"

    DecimalWrite:
      type: string
      pattern: '^-?\d+(\.\d+)?$'
      description: >-
        A non-money decimal written as a plain dot-decimal string (no comma, no
        exponent). A native JSON number is also accepted. Absolute ceiling
        9999999.9999.
      example: "2.5"

    # -------------------------------------------------------------- Enums

    CurrencyEnum:
      type: string
      enum: [EUR, GBP]

    ClientTypeEnum:
      type: string
      enum: [client, member]

    CollectionMethodEnum:
      type: string
      enum: [invoice, direct_debit]

    LocaleEnum:
      type: string
      enum: [en, nl]

    InvoiceStatusEnum:
      type: string
      enum: [draft, sent, paid]

    QuoteStatusEnum:
      type: string
      enum: [draft, sent, accepted, declined]

    ExpenseTypeEnum:
      type: string
      enum: [invoice, receipt, credit_note, subscription]

    ExpenseStatusEnum:
      type: string
      enum: [draft, pending, paid]

    ProductTypeEnum:
      type: string
      enum: [service, hourly, membership, physical, digital]

    ProjectStatusEnum:
      type: string
      enum: [active, completed, archived]

    DocumentSourceEnum:
      type: string
      enum: [email, upload]

    BankCategorizationEnum:
      type: string
      enum: [bank_fee, interest_received, btw_afdracht]

    EquityMovementTypeEnum:
      type: string
      enum: [deposit, withdrawal]

    BtwRateEnum:
      type: number
      enum: [0, 9, 21]
      description: A Dutch BTW (VAT) rate percentage.

    TaxTreatmentEnum:
      type: string
      enum: [standard, reverse_charge_eu, out_of_scope_non_eu_service, export_non_eu_goods]
      description: >-
        The cross-border VAT treatment of an invoice or quote. `standard` is
        normal Dutch BTW; `reverse_charge_eu` is the EU B2B reverse charge (btw
        verlegd); `out_of_scope_non_eu_service` is a service to a non-EU
        business, outside the scope of Dutch BTW; `export_non_eu_goods` is a
        0% export of physical goods to a destination outside the EU (rubriek
        3a) — goods lines only, and the 0% rate is conditional on export
        evidence managed in the Billey UI.

    LedgerTransactionTypeEnum:
      type: string
      enum:
        - invoice_sent
        - invoice_paid
        - invoice_reversed
        - expense_recorded
        - expense_paid
        - expense_reversed
        - btw_settlement
        - btw_settlement_reversed
        - bank_transaction
        - manual
        - pos_sale
        - pos_sale_reversed
        - pos_refund
        - pos_cash_reconciliation
        - pos_cash_paid_out
        - pos_cash_paid_out_reversed
        - inventory_opening
        - inventory_receipt
        - inventory_adjustment
        - inventory_return
        - inventory_reversed
        - asset_acquisition
        - asset_depreciation
        - asset_disposal
        - asset_reversed
        - asset_disposal_reversed
        - bank_suspense
        - bank_suspense_reversed
        - bank_categorization
        - bank_categorization_reversed
        - btw_afdracht
        - btw_afdracht_reversed
        - equity_deposit
        - equity_withdrawal
        - equity_reversed
        - year_end_close
        - year_end_close_reversed

    # -------------------------------------------------------------- Errors

    Error:
      type: object
      required: [message]
      properties:
        message:
          type: string

    ForbiddenError:
      type: object
      required: [message]
      properties:
        message:
          type: string
        required_ability:
          type: string
          description: The ability the token was missing (present only on ability failures).

    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          description: Field name to a list of error messages.

    # -------------------------------------------------------------- Pagination

    CursorLinks:
      type: object
      properties:
        first:
          type: [string, "null"]
          description: Always null for cursor pagination.
        last:
          type: [string, "null"]
          description: Always null for cursor pagination.
        prev:
          type: [string, "null"]
          description: URL of the previous page, or null.
        next:
          type: [string, "null"]
          description: URL of the next page, or null.

    CursorMeta:
      type: object
      properties:
        path:
          type: string
        per_page:
          type: integer
        next_cursor:
          type: [string, "null"]
        prev_cursor:
          type: [string, "null"]

    # -------------------------------------------------------------- Resources

    Client:
      type: object
      properties:
        id:
          type: integer
        client_number:
          type: [string, "null"]
        client_type:
          oneOf:
            - $ref: '#/components/schemas/ClientTypeEnum'
            - type: "null"
        member_number:
          type: [string, "null"]
        name:
          type: string
        company:
          type: [string, "null"]
        email:
          type: [string, "null"]
        phone:
          type: [string, "null"]
        address:
          type: [string, "null"]
          deprecated: true
          description: >-
            Deprecated (#746) — joined from address_line_1/2/3 with
            newlines for backwards compatibility. Use address_line_1/2/3
            instead.
        address_line_1:
          type: [string, "null"]
        address_line_2:
          type: [string, "null"]
        address_line_3:
          type: [string, "null"]
        postal_code:
          type: [string, "null"]
        city:
          type: [string, "null"]
        state:
          type: [string, "null"]
          description: Province/state — not every country uses one.
        country:
          type: [string, "null"]
          description: ISO 3166-1 alpha-2 country code.
        currency:
          oneOf:
            - $ref: '#/components/schemas/CurrencyEnum'
            - type: "null"
        locale:
          oneOf:
            - $ref: '#/components/schemas/LocaleEnum'
            - type: "null"
        chamber_of_commerce_number:
          type: [string, "null"]
        tax_number:
          type: [string, "null"]
        iban:
          type: [string, "null"]
        payment_terms_days:
          type: [integer, "null"]
        collection_method:
          oneOf:
            - $ref: '#/components/schemas/CollectionMethodEnum'
            - type: "null"
        mandate_id:
          type: [string, "null"]
        mandate_date:
          $ref: '#/components/schemas/DateOnly'
        notes:
          type: [string, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        deleted_at:
          $ref: '#/components/schemas/Timestamp'

    ClientSummary:
      type: object
      description: The slim client shape embedded inside invoices, quotes and projects.
      properties:
        id:
          type: integer
        client_number:
          type: [string, "null"]
        name:
          type: string
        company:
          type: [string, "null"]
        email:
          type: [string, "null"]
        city:
          type: [string, "null"]
        country:
          type: [string, "null"]

    Vendor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: [string, "null"]
        website:
          type: [string, "null"]
        iban:
          type: [string, "null"]
        bic:
          type: [string, "null"]
        notes:
          type: [string, "null"]
        known_sender_emails:
          type: [array, "null"]
          items:
            type: string
          description: Sender addresses used to auto-match inbound documents to this vendor.
        match_subject_keywords:
          type: [array, "null"]
          items:
            type: string
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        deleted_at:
          $ref: '#/components/schemas/Timestamp'

    VendorSummary:
      type: object
      description: The slim vendor shape embedded inside expenses.
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: [string, "null"]
        website:
          type: [string, "null"]

    Account:
      type: object
      description: A lean chart-of-accounts reference embedded inside expenses.
      properties:
        id:
          type: integer
        code:
          type: [string, "null"]
        name:
          type: [string, "null"]
        type:
          type: [string, "null"]

    Invoice:
      type: object
      properties:
        id:
          type: integer
        invoice_number:
          type: [string, "null"]
        status:
          oneOf:
            - $ref: '#/components/schemas/InvoiceStatusEnum'
            - type: "null"
        is_credit_note:
          type: boolean
        credited_invoice_id:
          type: [integer, "null"]
        currency:
          oneOf:
            - $ref: '#/components/schemas/CurrencyEnum'
            - type: "null"
        invoice_date:
          $ref: '#/components/schemas/DateOnly'
        due_date:
          $ref: '#/components/schemas/DateOnly'
        subtotal:
          $ref: '#/components/schemas/Money'
        btw_total:
          $ref: '#/components/schemas/Money'
        total:
          $ref: '#/components/schemas/Money'
        kor_applied:
          type: boolean
        reverse_charge_applied:
          type: boolean
          description: Derived compatibility flag — `true` exactly when `tax_treatment` is `reverse_charge_eu`.
        tax_treatment:
          $ref: '#/components/schemas/TaxTreatmentEnum'
        customer_tax_number:
          type: [string, "null"]
        seller_tax_number:
          type: [string, "null"]
        reminder_count:
          type: [integer, "null"]
        collection_status:
          type: [string, "null"]
        membership_cycle_started_on:
          $ref: '#/components/schemas/DateOnly'
        membership_cycle_interval:
          type: [string, "null"]
        sent_at:
          $ref: '#/components/schemas/Timestamp'
        paid_at:
          $ref: '#/components/schemas/Timestamp'
        scheduled_send_at:
          $ref: '#/components/schemas/Timestamp'
        last_reminded_at:
          $ref: '#/components/schemas/Timestamp'
        notes:
          type: [string, "null"]
        client_id:
          type: [integer, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        deleted_at:
          $ref: '#/components/schemas/Timestamp'
        client:
          $ref: '#/components/schemas/ClientSummary'
          description: Embedded client summary. Present on the standard read and webhook payloads (absent only if the relation was not loaded).
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/InvoiceLineItem'
          description: Present on the standard read and webhook payloads.

    InvoiceLineItem:
      type: object
      properties:
        id:
          type: integer
        description:
          type: [string, "null"]
        quantity:
          $ref: '#/components/schemas/Decimal'
        unit_price:
          $ref: '#/components/schemas/Money'
        btw_percentage:
          $ref: '#/components/schemas/Decimal'
        icp_category:
          $ref: '#/components/schemas/IcpCategoryEnum'
        btw_amount:
          $ref: '#/components/schemas/Money'
        line_total:
          $ref: '#/components/schemas/Money'
        sort_order:
          type: [integer, "null"]

    Quote:
      type: object
      properties:
        id:
          type: integer
        quote_number:
          type: [string, "null"]
        status:
          oneOf:
            - $ref: '#/components/schemas/QuoteStatusEnum'
            - type: "null"
        reverse_charge_applied:
          type: boolean
          description: Derived compatibility flag — `true` exactly when `tax_treatment` is `reverse_charge_eu`.
        tax_treatment:
          $ref: '#/components/schemas/TaxTreatmentEnum'
        currency:
          oneOf:
            - $ref: '#/components/schemas/CurrencyEnum'
            - type: "null"
        quote_date:
          $ref: '#/components/schemas/DateOnly'
        valid_until:
          $ref: '#/components/schemas/DateOnly'
        subtotal:
          $ref: '#/components/schemas/Money'
        btw_total:
          $ref: '#/components/schemas/Money'
        total:
          $ref: '#/components/schemas/Money'
        sent_at:
          $ref: '#/components/schemas/Timestamp'
        accepted_at:
          $ref: '#/components/schemas/Timestamp'
        declined_at:
          $ref: '#/components/schemas/Timestamp'
        converted_invoice_id:
          type: [integer, "null"]
        notes:
          type: [string, "null"]
        client_id:
          type: [integer, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        client:
          $ref: '#/components/schemas/ClientSummary'
          description: Embedded client summary. Present on the standard read payloads.
        line_items:
          type: array
          items:
            $ref: '#/components/schemas/QuoteLineItem'

    QuoteLineItem:
      type: object
      properties:
        id:
          type: integer
        description:
          type: [string, "null"]
        quantity:
          $ref: '#/components/schemas/Decimal'
        unit_price:
          $ref: '#/components/schemas/Money'
        btw_percentage:
          $ref: '#/components/schemas/Decimal'
        btw_amount:
          $ref: '#/components/schemas/Money'
        line_total:
          $ref: '#/components/schemas/Money'
        sort_order:
          type: [integer, "null"]

    Expense:
      type: object
      properties:
        id:
          type: integer
        description:
          type: [string, "null"]
        type:
          oneOf:
            - $ref: '#/components/schemas/ExpenseTypeEnum'
            - type: "null"
        status:
          oneOf:
            - $ref: '#/components/schemas/ExpenseStatusEnum'
            - type: "null"
        amount:
          $ref: '#/components/schemas/Money'
        vat_amount:
          $ref: '#/components/schemas/Money'
        vat_rate:
          $ref: '#/components/schemas/Decimal'
        currency:
          type: [string, "null"]
          description: Free-form currency code (NOT the constrained EUR/GBP enum used elsewhere).
        reference:
          type: [string, "null"]
        date:
          $ref: '#/components/schemas/DateOnly'
        due_date:
          $ref: '#/components/schemas/DateOnly'
        paid_at:
          $ref: '#/components/schemas/Timestamp'
        vendor_id:
          type: [integer, "null"]
        account_id:
          type: [integer, "null"]
        document_id:
          type: [integer, "null"]
        project_id:
          type: [integer, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        deleted_at:
          $ref: '#/components/schemas/Timestamp'
        vendor:
          $ref: '#/components/schemas/VendorSummary'
          description: Embedded vendor summary. Present on the standard read and webhook payloads.
        account:
          $ref: '#/components/schemas/Account'
          description: Embedded chart-of-accounts reference. Present on the standard read and webhook payloads.

    Document:
      type: object
      properties:
        id:
          type: integer
        source:
          oneOf:
            - $ref: '#/components/schemas/DocumentSourceEnum'
            - type: "null"
        original_filename:
          type: [string, "null"]
        mime_type:
          type: [string, "null"]
        has_pdf_attachment:
          type: boolean
        received_at:
          $ref: '#/components/schemas/Timestamp'
        sender_email:
          type: [string, "null"]
        email_subject:
          type: [string, "null"]
        extraction_status:
          type: [string, "null"]
        uploaded_by:
          type: [integer, "null"]
          description: Id of the user who uploaded the document (null for email-sourced documents).
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        deleted_at:
          $ref: '#/components/schemas/Timestamp'

    BankTransaction:
      type: object
      properties:
        id:
          type: integer
        amount:
          $ref: '#/components/schemas/Money'
        description:
          type: [string, "null"]
        counterpart_name:
          type: [string, "null"]
        counterpart_iban:
          type: [string, "null"]
        currency:
          type: [string, "null"]
          description: Free-form currency code (NOT the constrained EUR/GBP enum used elsewhere).
        reference:
          type: [string, "null"]
        external_transaction_id:
          type: [string, "null"]
        end_to_end_id:
          type: [string, "null"]
          description: The canonical SEPA end-to-end reconciliation identifier.
        bank_transaction_code:
          type: [string, "null"]
        purpose_code:
          type: [string, "null"]
        additional_information:
          type: [string, "null"]
        executed_at:
          $ref: '#/components/schemas/DateOnly'
        value_date:
          $ref: '#/components/schemas/DateOnly'
        bank_categorization:
          oneOf:
            - $ref: '#/components/schemas/BankCategorizationEnum'
            - type: "null"
        equity_type:
          oneOf:
            - $ref: '#/components/schemas/EquityMovementTypeEnum'
            - type: "null"
        invoice_id:
          type: [integer, "null"]
        expense_id:
          type: [integer, "null"]
        vendor_id:
          type: [integer, "null"]
        bank_account_id:
          type: [integer, "null"]
        bank_statement_id:
          type: [integer, "null"]
        is_matched:
          type: boolean
          description: Derived — whether the transaction is matched to an invoice/expense/vendor. Always present.
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    LedgerEntry:
      type: object
      properties:
        id:
          type: integer
        account:
          type: string
          description: >-
            The ledger account — a standard chart-of-accounts code, or a raw
            per-book account code for a custom category account.
        account_label:
          type: [string, "null"]
          description: Human label for the account, present only when it resolves to a known standard code.
        type:
          oneOf:
            - $ref: '#/components/schemas/LedgerTransactionTypeEnum'
            - type: "null"
        date:
          $ref: '#/components/schemas/DateOnly'
        debit:
          $ref: '#/components/schemas/Money'
        credit:
          $ref: '#/components/schemas/Money'
        description:
          type: [string, "null"]
        journalable_type:
          type: [string, "null"]
        journalable_id:
          type: [integer, "null"]
        posting_key:
          type: [string, "null"]
        product_id:
          type: [integer, "null"]
        reversed_at:
          $ref: '#/components/schemas/Timestamp'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    Product:
      type: object
      properties:
        id:
          type: integer
        name:
          type: [string, "null"]
        description:
          type: [string, "null"]
        type:
          oneOf:
            - $ref: '#/components/schemas/ProductTypeEnum'
            - type: "null"
        unit_price:
          $ref: '#/components/schemas/Money'
        cost_price:
          $ref: '#/components/schemas/Money'
        btw_percentage:
          $ref: '#/components/schemas/Decimal'
        is_active:
          type: boolean
        has_variants:
          type: boolean
        parent_id:
          type: [integer, "null"]
          description: Set on variant child rows; walk it to reach the parent product.
        track_stock:
          type: boolean
        stock_quantity:
          type: [integer, "null"]
        inventory_accounting_enabled:
          type: boolean
        weight_grams:
          type: [integer, "null"]
        sku:
          type: [string, "null"]
        barcode:
          type: [string, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    Project:
      type: object
      properties:
        id:
          type: integer
        name:
          type: [string, "null"]
        status:
          oneOf:
            - $ref: '#/components/schemas/ProjectStatusEnum'
            - type: "null"
        client_id:
          type: [integer, "null"]
        product_id:
          type: [integer, "null"]
        hourly_rate:
          $ref: '#/components/schemas/Money'
        btw_percentage:
          $ref: '#/components/schemas/Decimal'
        budget_hours:
          $ref: '#/components/schemas/Decimal'
        description:
          type: [string, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        client:
          $ref: '#/components/schemas/ClientSummary'
          description: Embedded client summary. Present on the standard read payloads.

    TimeEntry:
      type: object
      properties:
        id:
          type: integer
        project_id:
          type: [integer, "null"]
        invoice_id:
          type: [integer, "null"]
        is_invoiced:
          type: boolean
          description: Derived — whether this entry has been invoiced. Always present.
        date:
          $ref: '#/components/schemas/DateOnly'
        hours:
          $ref: '#/components/schemas/Decimal'
        description:
          type: [string, "null"]
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    # -------------------------------------------------------------- Write bodies

    ClientWritable:
      type: object
      description: >-
        Client fields accepted on write. `client_number` is never accepted (it is
        auto-generated). `date_of_birth` is accepted on write but never returned.
      properties:
        client_type:
          $ref: '#/components/schemas/ClientTypeEnum'
        member_number:
          type: [string, "null"]
          maxLength: 50
        date_of_birth:
          type: [string, "null"]
          format: date
        name:
          type: string
          maxLength: 255
        company:
          type: [string, "null"]
          maxLength: 255
        email:
          type: [string, "null"]
          format: email
          maxLength: 255
        phone:
          type: [string, "null"]
          maxLength: 255
        address:
          type: [string, "null"]
          maxLength: 1000
          deprecated: true
          description: >-
            Deprecated (#746) — accepted for backwards compatibility and
            split into address_line_1/2/3 on write. If any address_line_*
            field is ALSO present in the same request, the explicit line
            fields win and this key is ignored.
        address_line_1:
          type: [string, "null"]
          maxLength: 255
        address_line_2:
          type: [string, "null"]
          maxLength: 255
        address_line_3:
          type: [string, "null"]
          maxLength: 255
        postal_code:
          type: [string, "null"]
          maxLength: 20
        city:
          type: [string, "null"]
          maxLength: 255
        state:
          type: [string, "null"]
          maxLength: 255
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code.
        currency:
          $ref: '#/components/schemas/CurrencyEnum'
        chamber_of_commerce_number:
          type: [string, "null"]
          maxLength: 255
        tax_number:
          type: [string, "null"]
          maxLength: 255
        notes:
          type: [string, "null"]
        locale:
          $ref: '#/components/schemas/LocaleEnum'
        payment_terms_days:
          type: [integer, "null"]
          minimum: 1
          maximum: 90
        collection_method:
          $ref: '#/components/schemas/CollectionMethodEnum'
        iban:
          type: [string, "null"]
          maxLength: 34
          description: Required when `collection_method` is `direct_debit`.
        mandate_id:
          type: [string, "null"]
          maxLength: 35
        mandate_date:
          type: [string, "null"]
          format: date
          description: Required when `collection_method` is `direct_debit`.

    ClientCreate:
      allOf:
        - $ref: '#/components/schemas/ClientWritable'
        - type: object
          required: [client_type, name, country, currency, locale, collection_method]

    ClientUpdate:
      allOf:
        - $ref: '#/components/schemas/ClientWritable'
      description: Partial update — every field is optional; see the operation description for the direct-debit cross-field rule.

    VendorWritable:
      type: object
      description: >-
        Vendor fields accepted on write. `notes`, `known_sender_emails` and
        `match_subject_keywords` are returned on reads but are NOT settable
        through this API.
      properties:
        name:
          type: string
          maxLength: 255
        category:
          type: [string, "null"]
          maxLength: 255
        website:
          type: [string, "null"]
          maxLength: 255
        iban:
          type: [string, "null"]
          maxLength: 34
        bic:
          type: [string, "null"]
          maxLength: 11

    VendorCreate:
      allOf:
        - $ref: '#/components/schemas/VendorWritable'
        - type: object
          required: [name]

    VendorUpdate:
      allOf:
        - $ref: '#/components/schemas/VendorWritable'
      description: Partial update — every field is optional.

    IcpCategoryEnum:
      type: string
      enum: [goods, services]
      description: >-
        ICP-opgaaf column for a line. `goods` is a levering, `services` a
        dienst; an electronically supplied product counts as a service.

    InvoiceLineItemInput:
      type: object
      required: [description, quantity, unit_price, btw_percentage]
      properties:
        description:
          type: string
          maxLength: 255
        quantity:
          $ref: '#/components/schemas/DecimalWrite'
          description: Minimum 0.01.
        unit_price:
          $ref: '#/components/schemas/MoneyWrite'
          description: Minimum 0.
        btw_percentage:
          $ref: '#/components/schemas/BtwRateEnum'
        icp_category:
          $ref: '#/components/schemas/IcpCategoryEnum'
          description: >-
            Goods/services split for the ICP-opgaaf, which reports leveringen
            and diensten in separate columns. Only meaningful when the invoice
            uses the `reverse_charge_eu` tax treatment. Omit it and the
            administration's default classification is applied — it is never
            stored as null, so an unclassified line still lands in a column.

    InvoiceInput:
      type: object
      properties:
        client_id:
          type: integer
          description: Must be an existing, non-deleted client in this administration.
        currency:
          $ref: '#/components/schemas/CurrencyEnum'
        invoice_number:
          type: [string, "null"]
          description: Optional. When absent on create, the next number is allocated; on update, the current number is kept.
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
          description: Must be on or after `invoice_date`.
        notes:
          type: [string, "null"]
        reverse_charge_applied:
          type: boolean
          description: Legacy compatibility input — `true` maps to a `reverse_charge_eu` tax treatment. Prefer sending `tax_treatment` directly.
        tax_treatment:
          $ref: '#/components/schemas/TaxTreatmentEnum'
          description: Optional. The cross-border VAT treatment; defaults to `standard`.
        items:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/InvoiceLineItemInput'

    InvoiceCreate:
      allOf:
        - $ref: '#/components/schemas/InvoiceInput'
        - type: object
          required: [client_id, currency, invoice_date, due_date, items]
          properties:
            time_entry_ids:
              type: array
              description: Optional. Ids of uninvoiced time entries to claim onto the new invoice (create only).
              items:
                type: integer

    InvoiceUpdate:
      allOf:
        - $ref: '#/components/schemas/InvoiceInput'
        - type: object
          required: [client_id, currency, invoice_date, due_date, items]
      description: >-
        Full replace — resend every header field and the complete `items` array.
        Omitted `notes` becomes null; omitted `reverse_charge_applied` becomes
        false. Only a draft invoice may be updated.

    QuoteLineItemInput:
      type: object
      required: [description, quantity, unit_price, btw_percentage]
      properties:
        description:
          type: string
          maxLength: 255
        quantity:
          $ref: '#/components/schemas/DecimalWrite'
          description: Minimum 0.01.
        unit_price:
          $ref: '#/components/schemas/MoneyWrite'
          description: Minimum 0.
        btw_percentage:
          $ref: '#/components/schemas/BtwRateEnum'
        sort_order:
          type: integer
          minimum: 0
          description: Optional. Defaults to the line's position in the `items` array.

    QuoteInput:
      type: object
      properties:
        client_id:
          type: integer
          description: Must be an existing, non-deleted client in this administration.
        currency:
          $ref: '#/components/schemas/CurrencyEnum'
        quote_date:
          type: string
          format: date
        valid_until:
          type: string
          format: date
          description: Must be on or after `quote_date`.
        notes:
          type: [string, "null"]
        reverse_charge_applied:
          type: boolean
          description: Legacy compatibility input — `true` maps to a `reverse_charge_eu` tax treatment. Prefer sending `tax_treatment` directly.
        tax_treatment:
          $ref: '#/components/schemas/TaxTreatmentEnum'
          description: Optional. The cross-border VAT treatment; defaults to `standard`.
        items:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/QuoteLineItemInput'

    QuoteCreate:
      allOf:
        - $ref: '#/components/schemas/QuoteInput'
        - type: object
          required: [client_id, currency, quote_date, valid_until, items]
          properties:
            quote_number:
              type: [string, "null"]
              description: Optional. When absent, the next number is allocated automatically.

    QuoteUpdate:
      allOf:
        - $ref: '#/components/schemas/QuoteInput'
        - type: object
          required: [client_id, currency, quote_date, valid_until, items]
      description: >-
        Full replace — resend every header field and the complete `items`
        array. Omitted `notes` becomes null; omitted `reverse_charge_applied`
        becomes false. `quote_number` cannot be changed here — unlike
        invoices, quotes have no PATCH renumbering; any `quote_number` sent is
        ignored. Only a draft quote may be updated.

    ExpenseInput:
      type: object
      properties:
        vendor_id:
          type: [integer, "null"]
          description: Must be an existing, non-deleted vendor in this administration.
        account_id:
          type: [integer, "null"]
          description: Must be an existing ledger account in this administration.
        project_id:
          type: [integer, "null"]
          description: Must be an existing project in this administration.
        type:
          $ref: '#/components/schemas/ExpenseTypeEnum'
        amount:
          oneOf:
            - $ref: '#/components/schemas/MoneyWrite'
            - type: "null"
        vat_amount:
          oneOf:
            - $ref: '#/components/schemas/MoneyWrite'
            - type: "null"
        vat_rate:
          $ref: '#/components/schemas/BtwRateEnum'
        date:
          type: string
          format: date
        due_date:
          type: [string, "null"]
          format: date
          description: Must be on or after `date`.
        reference:
          type: [string, "null"]
          maxLength: 255
        description:
          type: [string, "null"]
          maxLength: 1000

    ExpenseCreate:
      allOf:
        - $ref: '#/components/schemas/ExpenseInput'
        - type: object
          required: [type, vat_rate, date]
          properties:
            document_id:
              type: [integer, "null"]
              description: >-
                Optional. Links an existing (e.g. email-ingested) document. Must
                exist and not be soft-deleted in this administration, and must not
                already be linked to another expense. Accepted only on create.
      description: >-
        Creates a Draft expense. Any `status` sent is ignored — the writer always
        creates a draft.

    ExpenseUpload:
      allOf:
        - $ref: '#/components/schemas/ExpenseCreate'
        - type: object
          properties:
            file:
              type: string
              format: binary
              description: >-
                Optional. A NEW receipt or invoice file to store and attach —
                the multipart-only alternative to `document_id`. Max 10 MB;
                allowed types: pdf, jpg, jpeg, png, webp, heic, tiff, tif, bmp,
                xml (validated by file content, not the filename). The upload
                becomes a first-class document (source `upload`) whose searchable
                text is extracted asynchronously, so it is created with
                `extraction_status: processing`. Mutually exclusive with
                `document_id` — sending both is a 422.
      description: >-
        The `multipart/form-data` body for creating an expense with a new
        uploaded document in one call. Identical to `ExpenseCreate` plus the
        optional binary `file`.

    ExpenseUpdate:
      allOf:
        - $ref: '#/components/schemas/ExpenseInput'
        - type: object
          properties:
            status:
              $ref: '#/components/schemas/ExpenseStatusEnum'
      description: >-
        Partial update — every field is optional. `status` carries the
        Draft/Pending/Paid transition and is accepted only here, not on create.
        `document_id` is not accepted on update. Touching either of
        `date` / `due_date` validates the pair against the resulting record state.

    ProjectWritable:
      type: object
      description: >-
        Project fields accepted on write. The computed aggregates
        (`total_hours`, `revenue`, `profit` …) are derived and appear on neither
        reads nor writes.
      properties:
        client_id:
          type: integer
          description: Must be a client in this book that is not deleted.
        product_id:
          type: [integer, "null"]
          description: >-
            Optional hourly product this project's rate defaults from. Must be a
            product in this book.
        name:
          type: string
          maxLength: 255
        description:
          type: [string, "null"]
        hourly_rate:
          $ref: '#/components/schemas/MoneyWrite'
          description: Minimum 0.
        btw_percentage:
          $ref: '#/components/schemas/BtwRateEnum'
        budget_hours:
          $ref: '#/components/schemas/DecimalWrite'
          description: Minimum 0. Omit or send null for no budget — null is never coerced to 0.

    ProjectCreate:
      allOf:
        - $ref: '#/components/schemas/ProjectWritable'
        - type: object
          required: [client_id, name, hourly_rate, btw_percentage]
      description: >-
        `status` is not accepted here — a new project is always `active`.

    ProjectUpdate:
      allOf:
        - $ref: '#/components/schemas/ProjectWritable'
        - type: object
          properties:
            status:
              $ref: '#/components/schemas/ProjectStatusEnum'
      description: >-
        Partial update — every field is optional. `status` carries the
        active/completed/archived transition and is accepted only here, not on
        create.

    TimeEntryWritable:
      type: object
      description: >-
        Time-entry fields accepted on write. `invoice_id` is never accepted —
        hours are billed by creating an invoice from the project, and letting a
        caller set it directly would mark hours billed that no invoice line
        covers.
      properties:
        project_id:
          type: integer
          description: >-
            Must belong to your book and must not be a deleted project. On update,
            may be changed to move the entry to another project in the same book.
        date:
          $ref: '#/components/schemas/DateOnly'
        hours:
          $ref: '#/components/schemas/DecimalWrite'
          description: Minimum 0.01.
        description:
          type: string
          maxLength: 255

    TimeEntryCreate:
      allOf:
        - $ref: '#/components/schemas/TimeEntryWritable'
        - type: object
          required: [project_id, date, hours, description]

    TimeEntryUpdate:
      allOf:
        - $ref: '#/components/schemas/TimeEntryWritable'
      description: >-
        Partial update — every field is optional. Refused with 409 once the entry
        has been invoiced.

    # -------------------------------------------------------------- Webhook envelopes

    WebhookEnvelope:
      type: object
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          format: uuid
          description: The event id. Equals the `X-Billey-Event-Id` header; use it as an idempotency key.
        type:
          type: string
          description: The event type. This signed field is authoritative over the `X-Billey-Event-Type` header.
        created_at:
          type: string
          format: date-time
          description: UTC ISO-8601 timestamp with a `Z` suffix.
        data:
          type: object
          description: The affected resource, in the same shape the REST API returns it.

    InvoiceCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [invoice.created]
            data:
              $ref: '#/components/schemas/Invoice'

    InvoiceSentEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [invoice.sent]
            data:
              $ref: '#/components/schemas/Invoice'

    InvoicePaidEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [invoice.paid]
            data:
              $ref: '#/components/schemas/Invoice'

    ExpenseCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [expense.created]
            data:
              $ref: '#/components/schemas/Expense'

    ExpensePaidEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [expense.paid]
            data:
              $ref: '#/components/schemas/Expense'

    ClientCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type:
              type: string
              enum: [client.created]
            data:
              $ref: '#/components/schemas/Client'
