openapi: 3.1.0

info:
  title: FiskHub API
  version: "1.0.0"
  summary: Croatian fiscalization for unattended payment devices.
  description: |
    FiskHub signs a sale, forwards it to the Croatian Tax Administration (FINA / APIS IT) and
    returns the JIR so the device can issue its receipt. Fiscalization is **synchronous**: one
    request in, one fiscal result out, typically in 1–3 seconds.

    ## The one thing to get right

    `POST /v1/invoices` returns **HTTP 200** even when the Tax Administration is unreachable. In
    that case the body carries a `zki`, a ZKI-based `qr_payload`, `status: "retrying"` and
    `jir: null`.

    **This is not an error.** The sale is legally valid, the ZKI plus the QR payload is
    everything the device needs to print a compliant receipt, and the device **must complete the
    sale**. FiskHub retries in the background until the invoice is confirmed. An integration
    that treats a missing JIR as a failure and blocks the sale makes the operator's compliance
    worse, not better.

    Only a `4xx` means the caller must change something. See
    <https://docs.fiskhub.com/guides/no-jir/>.

    ## Contract stability

    `/v1` never breaks. Changes to `/v1` are additive only: new optional request fields, new
    response fields, new enum members in fields documented as open, new error codes. Anything
    that would break an existing integration ships as `/v2`. Treat unknown response fields and
    unknown error codes as forward-compatible rather than fatal.

    ## Scope

    Croatian **B2C fiscalization of unattended / self-service devices** only (spec §2.2).
    There are no webhooks and no batch endpoint — the synchronous flow removes the need for
    both. eRačun (B2B) and eIzvještavanje are out of scope.
  termsOfService: https://fiskhub.com/terms
  contact:
    name: FiskHub API support
    url: https://docs.fiskhub.com
    email: api@fiskhub.com
  license:
    name: Proprietary
    identifier: LicenseRef-FiskHub-Proprietary

externalDocs:
  description: FiskHub API reference
  url: https://docs.fiskhub.com

servers:
  - url: https://api.fiskhub.com/v1
    description: Production. Accepts both `fh_live_…` and `fh_test_…` keys; the key decides which Tax Administration endpoint is used.
  - url: https://api-demo.fiskhub.com/v1
    description: |
      Demo. Test keys only, and every filing goes to the Tax Administration's test endpoint —
      a JIR from here has no legal effect. Use this one from the playground while you are
      integrating.

security:
  - bearerAuth: []

tags:
  - name: Invoices
    description: |
      Fiscalize a sale and read back what happened. This is the only endpoint a payment device
      needs at the point of sale.
    externalDocs:
      url: https://docs.fiskhub.com/guides/fiscalizing-a-sale/
  - name: Companies
    description: |
      The fiscal identity. OIB, VAT status, certificate and endpoint mode all live on the
      company — never on the tenant — because the Tax Administration rejects any message whose
      payload OIB differs from the signing certificate's OIB (`s005`).
  - name: Devices
    description: |
      Self-service devices. A device cannot fiscalize until the Tax Administration has assigned
      it an ISU number (*ISU broj*), which is obtained by registering the premises in ePorezna.
    externalDocs:
      url: https://docs.fiskhub.com/errors/hr.device_missing_isu/
  - name: Certificates
    description: |
      Per-company PKCS#12 signing certificates. Upload is write-only: key material enters the
      vault and never comes back out through the API, in any form, for any caller.
  - name: Countries
    description: The countries this deployment can fiscalize for.
  - name: Health
    description: Service health, and an echo-backed probe of the Tax Administration endpoint.

paths:
  /invoices:
    post:
      tags: [Invoices]
      operationId: createInvoice
      summary: Fiscalize a sale
      description: |
        Validates the sale locally, computes the ZKI, signs the XML, sends it to the Tax
        Administration and waits for the JIR. Typical latency 1–3 s; worst case about 9 s
        (two sequential attempts with a 4 s socket timeout each) before FiskHub gives up on
        the round trip and returns the ZKI anyway.

        ### Read the `status` field, not the presence of `jir`

        | `status` | `jir` | What the device must do |
        |---|---|---|
        | `confirmed` | present | Print the receipt with the JIR-based QR. |
        | `retrying` | `null` | **Print the receipt with the ZKI-based QR and complete the sale.** FiskHub keeps retrying. |
        | `failed` | `null` | **Print the receipt with the ZKI-based QR and complete the sale.** Then fix the configuration — a human is needed. |

        In all three cases `zki` and `qr_payload` are present and the sale is legally valid.
        Never block a sale on this call.

        ### Invoice number

        `number` (`BrOznRac`) is **optional**, and whoever owns the sequence supplies it:

        - Send `number` and it is used exactly as sent.
        - Omit it and FiskHub allocates the next one for that selling point and calendar year,
          inside the transaction that persists the invoice, so the sequence cannot race or gap
          under concurrency.

        The number used is always returned, and it is the number to print.

        The sequence is **per selling point**, never global. Unattended fiscalization sends
        `OznSlijed = P`, which means numbering belongs to the business premises and restarts each
        calendar year — two selling points legitimately both have a receipt `1`. What is unique is
        the business key `oib | oznPosPr | isuNumber | brOznRac | year`; reusing a number within
        that key is a `409` (`invoice_already_exists`), not a silent overwrite.

        ### Attended and unattended sales

        `sale_type` chooses which set of Croatian message rules applies, and defaults to
        `unattended` — so an integration that has never sent the field behaves exactly as before.

        | | `unattended` (§2.2) | `attended` (§2.1) |
        |---|---|---|
        | `OznSlijed` | `P` only — per business premises | `N` or `P` |
        | `OznNapUr` | the device's **ISU number**, from ePorezna | the **cash register's mark**, yours to assign |
        | `NacinPlac` | `G`, `K`, `O` | `G`, `K`, `O`, **`T`** |
        | `OibOper` | the company's own OIB — there is no cashier | the **operator's** OIB |
        | `OibPrimateljaRacuna` | not in the message at all | permitted (B2B) |
        | `ParagonBrRac` | explicitly unused | permitted |
        | Restrictive errors | 176–179, 182–184, v185 | those, **plus 180 and 181** |

        The attended-only fields are **rejected** on an unattended sale rather than ignored.
        Silently coercing `sequence_mark: "N"` to `P` would leave the caller believing their
        per-device numbering had been accepted.

        ### Idempotency

        `Idempotency-Key` is **required**. Same key + byte-identical body replays the stored
        response for 24 hours; same key + a different body is a `409`. This is the first line of
        defence against double-fiscalization.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
        - $ref: "#/components/parameters/IncludeMessages"
        - $ref: "#/components/parameters/RequestId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateInvoiceRequest"
            examples:
              cardSale:
                summary: A €4.50 card sale at 25% VAT
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T10:32:11+02:00"
                  total: "4.50"
                  currency: EUR
                  payment_method: K
                  vat:
                    - rate: "25.00"
                      base: "3.60"
                      amount: "0.90"
              cashSaleMixedVat:
                summary: Cash sale spanning two VAT rates
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T18:04:59+02:00"
                  total: "12.30"
                  currency: EUR
                  payment_method: G
                  vat:
                    - rate: "25.00"
                      base: "6.00"
                      amount: "1.50"
                    - rate: "5.00"
                      base: "4.57"
                      amount: "0.23"
                  special_purpose: "Automat 12 — kiosk"
              attendedB2bReceipt:
                summary: An attended (§2.1) B2B receipt issued by an operator
                description: |
                  `sale_type: attended` selects the ordinary-receipt rules: `OznSlijed` may be
                  `N`, `OznNapUr` is the cash register's own mark rather than an ISU number, the
                  operator has their own OIB, and a recipient OIB is permitted. Paid by card,
                  because a recipient OIB may not be combined with `T` (restrictive error 181).
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T14:07:33+02:00"
                  total: "150.00"
                  currency: EUR
                  payment_method: K
                  sale_type: attended
                  sequence_mark: N
                  register_mark: "1"
                  operator_oib: "69435151530"
                  recipient_oib: "25089460814"
                  vat:
                    - rate: "25.00"
                      base: "120.00"
                      amount: "30.00"
              attendedParagonCopyUp:
                summary: An attended receipt copying up a hand-written paragon
                description: |
                  What a till does after a failure: the sale was written by hand at the time and
                  is being reported now. `late_delivery` forces `NakDost` on the first attempt.
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-26T19:41:02+02:00"
                  total: "18.40"
                  currency: EUR
                  payment_method: G
                  sale_type: attended
                  register_mark: "1"
                  operator_oib: "69435151530"
                  paragon_number: "PARAGON-2026-0114"
                  late_delivery: true
                  vat:
                    - rate: "13.00"
                      base: "16.28"
                      amount: "2.12"
              explicitlyNoSpecialPurpose:
                summary: "`null` clears a configured default for this one sale"
                description: |
                  The device's `device_config` sets `special_purpose`, and this sale must not
                  carry it. Omitting the field would let the configured value through; `null`
                  stops the chain and omits the element.
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T10:32:11+02:00"
                  total: "4.50"
                  currency: EUR
                  payment_method: K
                  special_purpose: null
                  vat:
                    - rate: "25.00"
                      base: "3.60"
                      amount: "0.90"
              callerSuppliedNumber:
                summary: The caller keeps the receipt sequence and sends the number
                description: |
                  `number` is used exactly as sent. The sequence it comes from must be the one for
                  this selling point — `OznSlijed = P` numbering is per business premises and
                  restarts each calendar year.
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T10:32:11+02:00"
                  total: "4.50"
                  currency: EUR
                  payment_method: K
                  number: "1042"
                  vat:
                    - rate: "25.00"
                      base: "3.60"
                      amount: "0.90"
              withConsumptionTaxAndFees:
                summary: Sale with consumption tax, a fee and a non-taxable component
                value:
                  device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                  occurred_at: "2026-08-27T21:15:02+02:00"
                  total: "28.85"
                  currency: EUR
                  payment_method: O
                  vat:
                    - rate: "25.00"
                      base: "18.00"
                      amount: "4.50"
                  consumption_tax:
                    - rate: "3.00"
                      base: "18.00"
                      amount: "0.54"
                  non_taxable_amount: "3.31"
                  fees:
                    - name: "Povratna naknada"
                      amount: "2.50"
      responses:
        "200":
          description: |
            The sale has been recorded and a ZKI issued. **Always a valid sale** — inspect
            `status` to learn whether the JIR arrived. A `200` with `jir: null` is a success
            for the device: complete the sale.
          headers:
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttemptWithMessages"
              examples:
                confirmed:
                  summary: Confirmed — JIR received and its signature verified
                  value:
                    id: inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4
                    status: confirmed
                    jir: 2cf55235-9470-4b5c-a539-463f52b109d2
                    zki: e4d909c290d0fb1ca068ffaddf22cbd0
                    qr_payload: https://porezna.gov.hr/rn?jir=2cf55235-9470-4b5c-a539-463f52b109d2&datv=20260827_1032&izn=450
                    number: "1041"
                    occurred_at: "2026-08-27T10:32:11+02:00"
                    total: "4.50"
                    currency: EUR
                retryingNoJir:
                  summary: >-
                    NO JIR — the Tax Administration was unreachable. STILL A VALID SALE. Print the
                    ZKI QR and complete the sale.
                  description: |
                    `status` is `retrying`, `jir` is `null`, and `qr_payload` carries `zki=` instead
                    of `jir=`. The receipt printed from this response is compliant. FiskHub keeps
                    retrying in the background until the invoice is confirmed or reaches its 30-day
                    deadline.

                    Blocking the sale here is the single worst thing an integration can do.
                  value:
                    id: inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4
                    status: retrying
                    jir: null
                    zki: e4d909c290d0fb1ca068ffaddf22cbd0
                    qr_payload: https://porezna.gov.hr/rn?zki=e4d909c290d0fb1ca068ffaddf22cbd0&datv=20260827_1032&izn=450
                    number: "1041"
                    occurred_at: "2026-08-27T10:32:11+02:00"
                    total: "4.50"
                    currency: EUR
                    fiscal_error:
                      code: upstream_unavailable
                      message: Tax Administration did not respond. Will retry automatically.
                      retry_scheduled: true
                failedCertificate:
                  summary: >-
                    NO JIR — the certificate was rejected. STILL A VALID SALE. Print the ZKI QR,
                    complete the sale, then fix the certificate.
                  description: |
                    `status` is `failed` and `retry_scheduled` is `false`: retrying will not help,
                    because the cause is configuration rather than weather. The sale itself is
                    unaffected — the ZKI was computed locally and the receipt is compliant.
                  value:
                    id: inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4
                    status: failed
                    jir: null
                    zki: e4d909c290d0fb1ca068ffaddf22cbd0
                    qr_payload: https://porezna.gov.hr/rn?zki=e4d909c290d0fb1ca068ffaddf22cbd0&datv=20260827_1032&izn=450
                    number: "1041"
                    occurred_at: "2026-08-27T10:32:11+02:00"
                    total: "4.50"
                    currency: EUR
                    fiscal_error:
                      code: hr.certificate_oib_mismatch
                      message: >-
                        The signing certificate is issued to OIB 25089460814 but the company OIB is
                        12345678903. Upload the certificate belonging to this company.
                      retry_scheduled: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/InvoiceConflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

    get:
      tags: [Invoices]
      operationId: listInvoices
      summary: List invoices
      description: |
        Cursor-paginated, newest first by `occurred_at`. Every filter is optional and they
        combine with AND. Results are always scoped to the tenant and mode of the API key.
      parameters:
        - name: company_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Only invoices belonging to this company.
        - name: device_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Only invoices fiscalized by this device.
        - name: premises_code
          in: query
          required: false
          schema: { type: string, maxLength: 20 }
          description: Only invoices from this business premises (`OznPosPr`).
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/InvoiceStatus"
          description: Only invoices in this state. See the status lifecycle guide.
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: Inclusive lower bound on `occurred_at`, ISO 8601 with an offset.
          example: "2026-08-01T00:00:00+02:00"
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: Exclusive upper bound on `occurred_at`, ISO 8601 with an offset.
          example: "2026-09-01T00:00:00+02:00"
        - name: q
          in: query
          required: false
          schema: { type: string, minLength: 1, maxLength: 64 }
          description: >
            Free text over the receipt number, the JIR and the ZKI — the three identifiers a
            person actually holds. Case-insensitive substring match.
          example: "2cf55235"
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: Page size.
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Opaque cursor from `next_cursor` of the previous page. Never construct one by hand.
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: A page of invoices.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceList"
              examples:
                page:
                  summary: One confirmed and one still retrying
                  value:
                    object: list
                    data:
                      - id: inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4
                        status: confirmed
                        jir: 2cf55235-9470-4b5c-a539-463f52b109d2
                        zki: e4d909c290d0fb1ca068ffaddf22cbd0
                        qr_payload: https://porezna.gov.hr/rn?jir=2cf55235-9470-4b5c-a539-463f52b109d2&datv=20260827_1032&izn=450
                        number: "1041"
                        occurred_at: "2026-08-27T10:32:11+02:00"
                        total: "4.50"
                        currency: EUR
                        company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                        payment_method: K
                        mode: live
                        attempts: 1
                        late_delivery: false
                        deadline_at: "2026-09-26T10:32:11+02:00"
                        confirmed_at: "2026-08-27T10:32:13+02:00"
                        created_at: "2026-08-27T10:32:12+02:00"
                      - id: inv_01J9Z4V2P8N5H1D7S9F3G6J0K2
                        status: retrying
                        jir: null
                        zki: 7b1de0a44f9c3e5820a6cd1477bb92ef
                        qr_payload: https://porezna.gov.hr/rn?zki=7b1de0a44f9c3e5820a6cd1477bb92ef&datv=20260827_1041&izn=1230
                        number: "1042"
                        occurred_at: "2026-08-27T10:41:07+02:00"
                        total: "12.30"
                        currency: EUR
                        company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                        payment_method: G
                        mode: live
                        attempts: 4
                        late_delivery: true
                        deadline_at: "2026-09-26T10:41:07+02:00"
                        confirmed_at: null
                        created_at: "2026-08-27T10:41:08+02:00"
                        fiscal_error:
                          code: upstream_unavailable
                          message: Tax Administration did not respond. Will retry automatically.
                          retry_scheduled: true
                    has_more: true
                    next_cursor: Y3Vyc29yOjIwMjYtMDgtMjdUMTA6NDE6MDcrMDI6MDA
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /invoices/{id}:
    parameters:
      - $ref: "#/components/parameters/InvoiceId"
      - $ref: "#/components/parameters/RequestId"
    get:
      tags: [Invoices]
      operationId: getInvoice
      summary: Retrieve an invoice
      description: |
        Returns the stored invoice, including any status change made by the retry sweep since
        the original `POST`. This is how you find out that an invoice returned as `retrying`
        has since been `confirmed` — poll it, or read it from the dashboard. There are no
        webhooks.

        ### The raw XML

        `?include_messages=true` adds a `messages` array: the request and response XML of every
        attempt made on this invoice, oldest first, each with its own `message_uuid`,
        `attempt_no` and `duration_ms`.

        This is the **compliance audit trail** (`fiscal_messages`, append-only — never updated,
        never deleted), not telemetry. It exists so that what was submitted and what came back
        can be shown to an auditor years later. It is intended for session-authenticated
        dashboard use: the XML is large, and a device at the point of sale has no reason to
        fetch it. Do not mirror it in bulk.
      parameters:
        - $ref: "#/components/parameters/IncludeMessages"
      responses:
        "200":
          description: The invoice, with the audit trail when `include_messages=true`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceWithMessages"
              examples:
                confirmedLate:
                  summary: Returned as `retrying`, later confirmed by the sweep
                  value:
                    id: inv_01J9Z4V2P8N5H1D7S9F3G6J0K2
                    status: confirmed
                    jir: 8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14
                    zki: 7b1de0a44f9c3e5820a6cd1477bb92ef
                    qr_payload: https://porezna.gov.hr/rn?jir=8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14&datv=20260827_1041&izn=1230
                    number: "1042"
                    occurred_at: "2026-08-27T10:41:07+02:00"
                    total: "12.30"
                    currency: EUR
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                    payment_method: G
                    mode: live
                    attempts: 6
                    late_delivery: true
                    deadline_at: "2026-09-26T10:41:07+02:00"
                    confirmed_at: "2026-08-27T13:15:44+02:00"
                    created_at: "2026-08-27T10:41:08+02:00"
                expired:
                  summary: Past the 30-day cliff — permanently unfiscalizable
                  value:
                    id: inv_01J8Q1A0B3C5D7E9F1G3H5J7K9
                    status: expired
                    jir: null
                    zki: 4f22ab90c7d16e35892b0fa7c3d15e80
                    qr_payload: https://porezna.gov.hr/rn?zki=4f22ab90c7d16e35892b0fa7c3d15e80&datv=20260701_0912&izn=799
                    number: "0873"
                    occurred_at: "2026-07-01T09:12:30+02:00"
                    total: "7.99"
                    currency: EUR
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                    payment_method: K
                    mode: live
                    attempts: 20
                    late_delivery: true
                    deadline_at: "2026-07-31T09:12:30+02:00"
                    confirmed_at: null
                    created_at: "2026-07-01T09:12:31+02:00"
                    fiscal_error:
                      code: hr.sale_older_than_30_days
                      message: >-
                        The sale is more than 30 days old and can no longer be fiscalized
                        (restrictive error 176). This is a compliance incident — contact your
                        accountant.
                      retry_scheduled: false
                withMessages:
                  summary: "`?include_messages=true` — the audit trail of both attempts"
                  description: |
                    Two attempts: the first timed out and has no `response_xml`, the second
                    returned the JIR. The `message_uuid` values differ, as the specification
                    requires of `IdPoruke` on every attempt including retries.
                  value:
                    id: inv_01J9Z4V2P8N5H1D7S9F3G6J0K2
                    status: confirmed
                    jir: 8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14
                    zki: 7b1de0a44f9c3e5820a6cd1477bb92ef
                    qr_payload: https://porezna.gov.hr/rn?jir=8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14&datv=20260827_1041&izn=1230
                    number: "1042"
                    occurred_at: "2026-08-27T10:41:07+02:00"
                    total: "12.30"
                    currency: EUR
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    device_id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                    payment_method: G
                    mode: live
                    attempts: 2
                    late_delivery: true
                    deadline_at: "2026-09-26T10:41:07+02:00"
                    confirmed_at: "2026-08-27T13:15:44+02:00"
                    created_at: "2026-08-27T10:41:08+02:00"
                    last_error_class: null
                    messages:
                      - id: 5e1b7d90-3c42-4a08-8f61-0d29b4e7a135
                        message_uuid: c0a8f3d2-71b4-4e6a-9d38-5f2e1b7c0a94
                        attempt_no: 1
                        http_status: null
                        error_code: upstream_unavailable
                        error_message: Tax Administration did not respond within 4000 ms.
                        duration_ms: 4002
                        created_at: "2026-08-27T10:41:08+02:00"
                        request_xml: <?xml version="1.0" encoding="UTF-8"?><tns:RacunZahtjev …>
                        response_xml: null
                      - id: 9d4c2a71-0f38-4b52-a6e7-1c8b3d5f0e26
                        message_uuid: 7f31c0b5-9a2e-4d17-8c60-3b5a1e9d4f82
                        attempt_no: 2
                        http_status: 200
                        error_code: null
                        error_message: null
                        duration_ms: 611
                        created_at: "2026-08-27T13:15:44+02:00"
                        request_xml: <?xml version="1.0" encoding="UTF-8"?><tns:RacunZahtjev …>
                        response_xml: <?xml version="1.0" encoding="UTF-8"?><tns:RacunOdgovor …>
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /invoices/{id}/retry:
    post:
      tags: [Invoices]
      operationId: retryInvoice
      summary: Retry a failed invoice
      description: |
        Sends the invoice to the Tax Administration again, with `NakDost = true` (late delivery)
        and a fresh message UUID. The business data — OIB, datetime, number, premises code, ISU
        number, total — is byte-identical to the original attempt, so the ZKI does not change.

        Use this after fixing whatever made the invoice `failed`: uploading the right
        certificate, assigning an ISU number, replacing an expired certificate. Invoices in
        `retrying` are already picked up automatically every five minutes; calling this on one
        of them simply retries it sooner.

        Terminal statuses cannot be retried. `confirmed` already has a JIR — retrying would
        risk a second one, which invariant 1 forbids. `expired` is past the 30-day cliff and
        the Tax Administration will reject it permanently. Both return `409`.
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - $ref: "#/components/parameters/IncludeMessages"
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: |
            The retry was attempted. Read `status` for the outcome — it may still be `retrying`
            if the Tax Administration is still unreachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttemptWithMessages"
              examples:
                nowConfirmed:
                  summary: The retry succeeded
                  value:
                    id: inv_01J9Z4V2P8N5H1D7S9F3G6J0K2
                    status: confirmed
                    jir: 8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14
                    zki: 7b1de0a44f9c3e5820a6cd1477bb92ef
                    qr_payload: https://porezna.gov.hr/rn?jir=8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14&datv=20260827_1041&izn=1230
                    number: "1042"
                    occurred_at: "2026-08-27T10:41:07+02:00"
                    total: "12.30"
                    currency: EUR
                stillDown:
                  summary: The Tax Administration is still unreachable
                  value:
                    id: inv_01J9Z4V2P8N5H1D7S9F3G6J0K2
                    status: retrying
                    jir: null
                    zki: 7b1de0a44f9c3e5820a6cd1477bb92ef
                    qr_payload: https://porezna.gov.hr/rn?zki=7b1de0a44f9c3e5820a6cd1477bb92ef&datv=20260827_1041&izn=1230
                    number: "1042"
                    occurred_at: "2026-08-27T10:41:07+02:00"
                    total: "12.30"
                    currency: EUR
                    fiscal_error:
                      code: upstream_unavailable
                      message: Tax Administration did not respond. Will retry automatically.
                      retry_scheduled: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The invoice is in a terminal state and cannot be retried.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                alreadyConfirmed:
                  summary: Already has a JIR
                  value:
                    error:
                      type: conflict
                      code: invalid_request
                      message: >-
                        Invoice inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4 is already confirmed with JIR
                        2cf55235-9470-4b5c-a539-463f52b109d2. A sale is never fiscalized twice.
                      param: id
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/invalid_request
                expired:
                  summary: Past the 30-day deadline
                  value:
                    error:
                      type: conflict
                      code: hr.sale_older_than_30_days
                      message: >-
                        Invoice inv_01J8Q1A0B3C5D7E9F1G3H5J7K9 occurred on 2026-07-01 and passed its
                        30-day fiscalization deadline on 2026-07-31. It can no longer be submitted.
                      param: id
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/hr.sale_older_than_30_days
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /companies:
    get:
      tags: [Companies]
      operationId: listCompanies
      summary: List companies
      description: |
        Every company the API key can see, newest first.

        `?all_tenants=true` widens this to every tenant, for FiskHub platform operators only.
        See the parameter for the conditions — nothing about a tenant user's access changes,
        with the parameter or without it.
      parameters:
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/EntityStatus"
        - $ref: "#/components/parameters/AllTenants"
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: A page of companies.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyList"
              examples:
                page:
                  value:
                    object: list
                    data:
                      - id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        name: AWAIT 8 J.D.O.O.
                        oib: "25089460814"
                        vat_registered: true
                        address:
                          street: Ilica 1
                          city: Zagreb
                          postal_code: "10000"
                          country: HR
                        fiscal_config:
                          sequence_mode: P
                        mode: live
                        status: active
                        certificate_status: valid
                        certificate_expires_at: "2028-09-22T12:00:00+02:00"
                        created_at: "2026-06-14T08:20:11+02:00"
                    has_more: false
                    next_cursor: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Companies]
      operationId: createCompany
      summary: Create a company
      description: |
        The OIB is validated against the ISO 7064 MOD 11,10 checksum before anything is stored.

        A new company cannot fiscalize yet: it needs a certificate whose subject OIB matches
        this one (`POST /v1/certificates`) and a device with a premises code and an
        assigned ISU number.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - $ref: "#/components/parameters/RequestId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCompanyRequest"
            examples:
              vatRegistered:
                summary: A VAT-registered company in test mode
                value:
                  name: AWAIT 8 J.D.O.O.
                  oib: "25089460814"
                  vat_registered: true
                  address:
                    street: Ilica 1
                    city: Zagreb
                    postal_code: "10000"
                    country: HR
                  mode: test
      responses:
        "201":
          description: The company was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Company"
              examples:
                created:
                  value:
                    id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    name: AWAIT 8 J.D.O.O.
                    oib: "25089460814"
                    vat_registered: true
                    address:
                      street: Ilica 1
                      city: Zagreb
                      postal_code: "10000"
                      country: HR
                    fiscal_config: {}
                    mode: test
                    status: active
                    certificate_status: missing
                    certificate_expires_at: null
                    created_at: "2026-08-27T09:00:00+02:00"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A company with this OIB already exists in this tenant and mode.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                duplicateOib:
                  value:
                    error:
                      type: conflict
                      code: invalid_request
                      message: A company with OIB 25089460814 already exists in this tenant.
                      param: oib
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/invalid_request
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /companies/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
      - $ref: "#/components/parameters/RequestId"
    patch:
      tags: [Companies]
      operationId: updateCompany
      summary: Update a company
      description: |
        Only the fields present in the body are changed.

        `oib` is **immutable**. The certificate is bound to it, the OIB is part of every
        invoice's `business_key`, and changing it would silently orphan the company's fiscal
        history. Create a new company instead.

        Switching `mode` from `test` to `live` requires a valid certificate for this company;
        without one the request is rejected with `company_missing_certificate`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateCompanyRequest"
            examples:
              goLive:
                summary: Promote to live once the real certificate is uploaded
                value:
                  mode: live
              rename:
                value:
                  name: AWAIT 8 j.d.o.o.
      responses:
        "200":
          description: The updated company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Company"
              examples:
                updated:
                  value:
                    id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    name: AWAIT 8 J.D.O.O.
                    oib: "25089460814"
                    vat_registered: true
                    address:
                      street: Ilica 1
                      city: Zagreb
                      postal_code: "10000"
                      country: HR
                    fiscal_config:
                      sequence_mode: P
                    mode: live
                    status: active
                    certificate_status: valid
                    certificate_expires_at: "2028-09-22T12:00:00+02:00"
                    created_at: "2026-06-14T08:20:11+02:00"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /devices:
    get:
      tags: [Devices]
      operationId: listDevices
      summary: List devices
      description: |
        Filter by `isu_status=pending` to find every device that cannot yet fiscalize.

        `?all_tenants=true` widens this to every tenant, for FiskHub platform operators only,
        under exactly the conditions described on `GET /v1/companies`.
      parameters:
        - name: company_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: premises_code
          in: query
          required: false
          schema: { type: string, maxLength: 20 }
          description: Only devices at this business premises (`OznPosPr`).
        - $ref: "#/components/parameters/AllTenants"
        - name: isu_status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/IsuStatus"
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/EntityStatus"
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: A page of devices.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceList"
              examples:
                page:
                  summary: One live device, one still waiting for its ISU number
                  value:
                    object: list
                    data:
                      - id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                        company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        label: VM-0042
                        isu_number: "42"
                        isu_status: assigned
                        device_config:
                          model: K3
                        status: active
                        installed_at: "2026-06-20T11:00:00+02:00"
                        last_seen_at: "2026-08-27T10:41:07+02:00"
                        last_success_at: "2026-08-27T10:32:13+02:00"
                        created_at: "2026-06-20T10:55:41+02:00"
                      - id: b74a1c33-8e2f-4d05-91a6-3c7b5e0d2f18
                        company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        label: VM-0043
                        isu_number: null
                        isu_status: pending
                        device_config: {}
                        status: active
                        installed_at: null
                        last_seen_at: null
                        last_success_at: null
                        created_at: "2026-08-26T16:12:09+02:00"
                    has_more: false
                    next_cursor: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Devices]
      operationId: createDevice
      summary: Create a device
      description: |
        `isu_number` is optional here, so a device can be provisioned before the Tax
        Administration has issued one. Such a device is created with `isu_status: "pending"`
        and **cannot fiscalize** — `POST /v1/invoices` against it returns
        `hr.device_missing_isu`.

        FiskHub cannot obtain an ISU number for you. It is issued when the business premises
        is registered in **ePorezna**, which has never been available over the fiscalization
        web service. Enter it here once the Tax Administration has assigned it.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - $ref: "#/components/parameters/RequestId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateDeviceRequest"
            examples:
              withIsu:
                summary: ISU number already assigned — ready to fiscalize
                value:
                  company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                  label: VM-0042
                  isu_number: "42"
                  device_config:
                    model: K3
              awaitingIsu:
                summary: Provisioned ahead of the ePorezna registration
                value:
                  company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                  label: VM-0043
      responses:
        "201":
          description: The device was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Device"
              examples:
                created:
                  value:
                    id: 6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    label: VM-0042
                    isu_number: "42"
                    isu_status: assigned
                    device_config:
                      model: K3
                    status: active
                    installed_at: null
                    last_seen_at: null
                    last_success_at: null
                    created_at: "2026-08-27T09:10:00+02:00"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The `label` or the `isu_number` is already taken within this company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                duplicateIsu:
                  value:
                    error:
                      type: conflict
                      code: invalid_request
                      message: >-
                        ISU number 42 is already assigned to device VM-0042. Each ISU number
                        identifies exactly one self-service device.
                      param: isu_number
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/invalid_request
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /devices/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
      - $ref: "#/components/parameters/RequestId"
    patch:
      tags: [Devices]
      operationId: updateDevice
      summary: Update a device
      description: |
        The usual reason to call this is to attach an ISU number once ePorezna has issued it.
        Setting a non-null `isu_number` moves `isu_status` from `pending` to `assigned`, and the
        device can fiscalize from the next request onwards.

        `company_id` and `premises_code` are immutable. A device belongs to the fiscal identity
        that signs for it, and `premises_code` is `OznPosPr` — changing it would change the
        business key and the ZKI of every future receipt and move the device into a different
        receipt-number sequence. Archive the device and register a new one at the new premises.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateDeviceRequest"
            examples:
              assignIsu:
                summary: The ISU number arrived from ePorezna
                value:
                  isu_number: "43"
              retire:
                value:
                  status: archived
      responses:
        "200":
          description: The updated device.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Device"
              examples:
                isuAssigned:
                  value:
                    id: b74a1c33-8e2f-4d05-91a6-3c7b5e0d2f18
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    label: VM-0043
                    isu_number: "43"
                    isu_status: assigned
                    device_config: {}
                    status: active
                    installed_at: null
                    last_seen_at: null
                    last_success_at: null
                    created_at: "2026-08-26T16:12:09+02:00"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The `label` or the `isu_number` is already taken within this company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /devices/import:
    post:
      tags: [Devices]
      operationId: importDevices
      summary: Bulk-import devices and ISU numbers from CSV
      description: |
        Upload a CSV to create devices, or to attach ISU numbers to devices that already exist.
        This is the practical way to apply a batch of ISU numbers after registering several
        premises in ePorezna.

        ### CSV format

        UTF-8, comma-separated, with a header row. Recognised columns:

        | Column | Required | Meaning |
        |---|---|---|
        | `label` | yes | Device label. Unique within the company; the match key for updates. |
        | `premises_code` | yes | The business premises code (`OznPosPr`) as registered in ePorezna. |
        | `isu_number` | no | Digits, no leading zeros. Leave blank if ePorezna has not issued one yet. |
        | `device_config` | no | JSON object as a single quoted field. Merged into the existing config. |

        Unknown columns are ignored, so an export enriched with extra fields still imports.

        ```csv
        label,premises_code,isu_number,device_config
        VM-0042,POSL1,42,"{""model"":""K3""}"
        VM-0043,POSL1,43,
        VM-0044,POSL2,,
        ```

        ### Behaviour

        Each row is matched on `(company_id, label)`: an existing device is updated, a new label
        creates a device. Rows are processed independently and **valid rows are applied even if
        others fail** — the response lists every rejected row with its line number and the
        error code. Re-uploading a corrected file is safe, because applying the same row twice
        produces the same device.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - $ref: "#/components/parameters/RequestId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [company_id, file]
              properties:
                company_id:
                  type: string
                  format: uuid
                  description: The company every row belongs to.
                file:
                  type: string
                  format: binary
                  description: The CSV file. Maximum 1 MB, maximum 5000 data rows.
                dry_run:
                  type: boolean
                  default: false
                  description: Validate and report without writing anything.
            encoding:
              file:
                contentType: text/csv
      responses:
        "200":
          description: |
            The file was parsed. Valid rows have been applied unless `dry_run` was true. Check
            `failed` and `errors` — a `200` here does not mean every row succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceImportResult"
              examples:
                partialFailure:
                  summary: 3 rows applied, 1 rejected
                  value:
                    object: device_import
                    dry_run: false
                    processed: 4
                    created: 2
                    updated: 1
                    failed: 1
                    errors:
                      - row: 4
                        label: VM-0045
                        code: invalid_request
                        message: Premises code "POSL 9" is not valid — OznPosPr allows only 0-9, a-z and A-Z.
                        param: premises_code
                clean:
                  summary: Every row applied
                  value:
                    object: device_import
                    dry_run: false
                    processed: 120
                    created: 120
                    updated: 0
                    failed: 0
                    errors: []
        "400":
          description: The file could not be parsed at all — wrong encoding, missing header, too large.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                missingHeader:
                  value:
                    error:
                      type: validation_error
                      code: invalid_request
                      message: 'CSV header is missing the required column "label".'
                      param: file
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/invalid_request
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: The file exceeds 1 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /certificates:
    get:
      tags: [Certificates]
      operationId: listCertificates
      summary: List certificate metadata
      description: |
        **Metadata only.** This endpoint never returns the PKCS#12, the private key, the
        passphrase, or any encoding of them. There is no export path in the API, by design.
      parameters:
        - name: company_id
          in: query
          required: false
          schema: { type: string, format: uuid }
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/CertificateStatus"
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: Certificate metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertificateList"
              examples:
                page:
                  value:
                    object: list
                    data:
                      - id: c1d9e7f2-4a63-4b0e-8d15-7f2c9a3b6e04
                        company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                        label: FISKAL 1 (demo)
                        subject_oib: "25089460814"
                        subject_cn: FISKAL 1
                        issuer: Fina Demo CA 2020
                        serial: "2f1a4c8b93d0"
                        not_before: "2025-09-22T12:00:00+02:00"
                        not_after: "2028-09-22T12:00:00+02:00"
                        days_until_expiry: 751
                        status: valid
                        uploaded_at: "2026-06-14T08:44:19+02:00"
                    has_more: false
                    next_cursor: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Certificates]
      operationId: uploadCertificate
      summary: Upload a company signing certificate
      description: |
        Write-only. The PKCS#12 is streamed over TLS, decrypted in memory, re-encrypted under a
        wrapped data key and stored. It is never written to disk in the clear, never logged,
        never returned, and there is no endpoint that reads it back.

        Before it is accepted, FiskHub checks that:

        - the passphrase opens the container and it contains exactly one private key;
        - the chain validates against the trusted roots for this mode;
        - the validity window covers now;
        - **the OIB in the certificate subject equals the company's OIB.** A mismatch is
          rejected here rather than becoming an `s005` from the Tax Administration at the point
          of sale.

        Uploading a certificate for a company that already has one supersedes the old one; the
        previous certificate is marked `superseded` and stops being used for signing.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
        - $ref: "#/components/parameters/RequestId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [company_id, file, passphrase]
              properties:
                company_id:
                  type: string
                  format: uuid
                file:
                  type: string
                  format: binary
                  description: The PKCS#12 container (`.p12` / `.pfx`). Maximum 256 KB.
                passphrase:
                  type: string
                  format: password
                  description: >-
                    Used once, in memory, to open the container. Never stored and never logged.
                label:
                  type: string
                  maxLength: 200
                  description: Human-readable name shown in the dashboard.
            encoding:
              file:
                contentType: application/x-pkcs12
      responses:
        "201":
          description: The certificate was accepted. Metadata only — no key material.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Certificate"
              examples:
                accepted:
                  value:
                    id: c1d9e7f2-4a63-4b0e-8d15-7f2c9a3b6e04
                    company_id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                    label: FISKAL 1 (demo)
                    subject_oib: "25089460814"
                    subject_cn: FISKAL 1
                    issuer: Fina Demo CA 2020
                    serial: "2f1a4c8b93d0"
                    not_before: "2025-09-22T12:00:00+02:00"
                    not_after: "2028-09-22T12:00:00+02:00"
                    days_until_expiry: 751
                    status: valid
                    uploaded_at: "2026-08-27T09:15:33+02:00"
        "400":
          description: |
            The container could not be opened, failed validation, or does not belong to this
            company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                oibMismatch:
                  value:
                    error:
                      type: validation_error
                      code: hr.certificate_oib_mismatch
                      message: >-
                        The certificate is issued to OIB 25089460814 but company
                        3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57 has OIB 12345678903. Certificates are
                        per company — upload the one belonging to this OIB.
                      param: file
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/hr.certificate_oib_mismatch
                expired:
                  value:
                    error:
                      type: validation_error
                      code: certificate_expired
                      message: The certificate expired on 2026-03-01. Upload a current one.
                      param: file
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/certificate_expired
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: The file exceeds 256 KB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /certificates/{id}:
    get:
      tags: [Certificates]
      operationId: getCertificate
      summary: Fetch one certificate's metadata
      description: |
        Metadata only, exactly as the listing returns it. The `SELECT` behind this
        endpoint physically excludes `ciphertext`, `dek_wrapped`, `nonce` and
        `auth_tag` — key material is not fetched and then filtered, it is never
        fetched.

        Dashboard sessions only; an API key is a device credential and gets `403`.
      security:
        - sessionCookie: []
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: The certificate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Certificate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /certificates/{id}/archive:
    post:
      tags: [Certificates]
      operationId: archiveCertificate
      summary: Take a certificate out of service
      description: |
        Archiving is the only transition a certificate has, which is why it is a
        named action rather than a status `PATCH` — there is no `PATCH
        /certificates/{id}`.

        There is no delete either. `certificates_active_uniq` permits one active
        certificate per company per mode, so a replacement means archiving the
        incumbent — and the archived row has to survive, because the invoices it
        signed stay auditable against it.

        Uploading a replacement archives the incumbent on its own, atomically. Call
        this only to take a company **out** of service deliberately: with no active
        certificate that company's entire fleet stops fiscalizing with
        `company_missing_certificate`.

        Idempotent — archiving an already-archived certificate returns it unchanged.
      security:
        - sessionCookie: []
      parameters:
        - $ref: "#/components/parameters/ResourceId"
      responses:
        "200":
          description: The archived certificate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Certificate"
              example:
                id: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57
                status: archived
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /countries:
    get:
      tags: [Countries]
      operationId: listCountries
      summary: List the countries this deployment can fiscalize for
      description: |
        One entry per registered fiscalization provider, with the capabilities it supports and
        the filing deadline it enforces.

        This replaces `GET /v1/countries/{code}/schemas`, which published each country's device
        fields as JSON Schema so a dashboard could generate a form from them. That form
        collected asset data — serial number, manufacturer, model, a note — none of which
        reaches a fiscal message, while the `device_config` keys that DO change one were offered
        by no form at all. The deadline was the only thing that endpoint published which a
        caller needs, so it moved here.
      security: []
      parameters:
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: The supported countries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Country"
              examples:
                hr:
                  value:
                    - code: HR
                      capabilities: [unattended_sale, ordinary_receipt, echo]
                      fiscalization_deadline_days: 30
        "401":
          $ref: "#/components/responses/Unauthorized"
  /health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Service health
      description: |
        FiskHub's own liveness, and which build is answering. Unauthenticated, cheap, safe to
        poll. It says nothing about the Tax Administration — use `/v1/health/fiscal` for that.

        `version`, `commit` and `built_at` are stamped into the image by CI, so this is how you
        confirm what a stack is actually running without shelling into the host.
      security: []
      responses:
        "200":
          description: The service is up. `status` may still be `degraded`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
              examples:
                ok:
                  value:
                    status: ok
                    mode: test
                    environment: production
                    version: "0.1.0"
                    commit: 4f2a9c1e8b7d3a5f6c0e1b2d3a4f5c6e7b8d9a0f
                    built_at: "2026-09-08T13:04:11Z"
                    checks:
                      database: { ok: true, latency_ms: 3 }
        "503":
          description: A dependency the service cannot work without is unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
              examples:
                dbDown:
                  value:
                    status: degraded
                    mode: test
                    environment: production
                    version: "0.1.0"
                    commit: 4f2a9c1e8b7d3a5f6c0e1b2d3a4f5c6e7b8d9a0f
                    built_at: "2026-09-08T13:04:11Z"
                    checks:
                      database: { ok: false, latency_ms: 2000 }
        "429":
          description: |
            Too many requests from this IP. The per-IP budget is 20,000 per minute; a monitor
            polling every 10 s is nowhere near it.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorBody"
              examples:
                rateLimited:
                  value:
                    error:
                      type: rate_limit_error
                      code: rate_limited
                      message: Too many requests. Slow down and retry; see the Retry-After header.
                      param: null
                      upstream: null
                      doc_url: https://docs.fiskhub.com/errors/rate_limited

  /health/fiscal:
    get:
      tags: [Health]
      operationId: getFiscalHealth
      summary: Tax Administration reachability
      description: |
        Sends the Tax Administration's `EchoRequest` (spec §2.4) and reports whether a matching
        `EchoResponse` came back, and how long it took.

        **No authentication, no certificate, no tenant.** Echo is an unsigned round trip carrying
        no invoice data, so it answers "is the service reachable" without touching anyone's
        signing key — which is what makes it usable from an orchestrator or an uptime monitor
        that holds no credential.

        The mode is a property of the deployment, not of the request: there is no parameter that
        points the probe at the other endpoint.

        Results are cached for about 15 seconds and concurrent callers share one in-flight probe.
        Without that, a few open dashboards would turn this endpoint into a small
        denial-of-service against the one service we cannot afford to annoy.

        ### A probe, not a gate

        **A `reachable: true` does not guarantee that the next `POST /v1/invoices` succeeds.** It
        says the endpoint answered a few seconds ago; it says nothing about your certificate,
        your ISU number, the contents of your sale, or whether the service is still answering
        now.

        **A `reachable: false` does not excuse blocking a sale.** Fiscalize anyway, take the ZKI,
        print the receipt and let the retry sweep catch up. The rule that a missing JIR never
        stops a sale holds regardless of what this endpoint reports.

        Do not call it before each sale. Call it when invoices start coming back as `retrying`
        and you want to know whether the problem is theirs or yours.
      security: []
      parameters:
        - $ref: "#/components/parameters/RequestId"
      responses:
        "200":
          description: |
            The probe ran. `reachable` tells you the outcome — a `200` here does not mean the
            Tax Administration is up. It is `200` in both cases on purpose: reporting a third
            party's outage as our own `503` only gets our container restarted, which fixes
            nothing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FiscalHealth"
              examples:
                reachable:
                  summary: The endpoint answered — which is not a promise about the next sale
                  value:
                    reachable: true
                    latency_ms: 412
                    endpoint: https://cis.porezna-uprava.hr:8449/FiskalizacijaService
                    mode: live
                    checked_at: "2026-08-27T10:32:00+02:00"
                    detail: null
                unreachable:
                  summary: The Tax Administration is not answering — keep selling
                  value:
                    reachable: false
                    latency_ms: 4002
                    endpoint: https://cis.porezna-uprava.hr:8449/FiskalizacijaService
                    mode: live
                    checked_at: "2026-08-27T10:32:00+02:00"
                    detail: "Echo timed out after 4000 ms"
                testWindow:
                  summary: >-
                    A scheduled TEST maintenance window — indistinguishable from any other outage
                  description: |
                    The TEST service is unavailable on weekdays 16:00–17:00 and Sundays
                    08:00–12:00 (Europe/Zagreb). Nothing on the wire distinguishes a scheduled
                    outage from an unscheduled one, so the probe simply reports it as unreachable.
                    CI has to tolerate this.
                  value:
                    reachable: false
                    latency_ms: 4001
                    endpoint: https://cistest.apis-it.hr:8449/FiskalizacijaServiceTest
                    mode: test
                    checked_at: "2026-08-27T16:20:00+02:00"
                    detail: "Echo timed out after 4000 ms"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer fh_live_…` or `Authorization: Bearer fh_test_…`.

        The prefix decides everything downstream: `fh_test_` keys reach the Tax Administration's
        TEST endpoint with demo certificates and produce JIRs with no legal effect; `fh_live_`
        keys reach production and produce real fiscal records. There is no request parameter
        that switches modes — swapping the key is the only way, so a test key can never
        accidentally file a real receipt.

        Keys are shown once at creation and stored only as a SHA-256 hash. A lost key is
        replaced, not recovered.

  parameters:
    IncludeMessages:
      name: include_messages
      in: query
      required: false
      schema: { type: boolean, default: false }
      description: |
        Include the `fiscal_messages` audit trail — the raw request and response XML of every
        attempt made on this invoice, oldest first. Omitted or `false`, the response has no
        `messages` key at all, so the documented shape is unchanged either way.

        Accepted as the `X-Include-Messages: true` header too. It is deliberately **not** a
        body field on `POST`: the body is hashed for idempotency, so a debug flag carried
        there would turn an otherwise identical resend into a `409`
        (`idempotency_key_reused`).

        The XML is large. It is meant for verifying an integration and for showing an auditor
        what was submitted — not for mirroring in bulk from the point of sale.
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique key for this sale, at most 255 characters. A UUIDv4 generated once per
        transaction, and reused unchanged across every retry of that transaction, is the
        recommended form.

        Same key + identical body within 24 hours replays the stored response and sets
        `Idempotent-Replayed: true`. Same key + a different body is a `409`
        (`idempotency_key_reused`). Omitting it is a `400` (`missing_idempotency_key`).
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: 8f0b4e2a-6c31-4d9f-9a75-2b8e1c0d47f3

    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional here, and honoured the same way as on `POST /v1/invoices`: identical body
        replays for 24 hours, different body is a `409`.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: 8f0b4e2a-6c31-4d9f-9a75-2b8e1c0d47f3

    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: |
        Your own correlation id, echoed back on the response and attached to every log line for
        this request. If you omit it, FiskHub generates one. Quote it in support requests.
      schema:
        type: string
        maxLength: 200
      example: req-7f3c9a21

    AllTenants:
      name: all_tenants
      in: query
      required: false
      description: |
        List across **every tenant**, for FiskHub platform operators. Supported on
        `GET /v1/companies` and `GET /v1/devices`, and nowhere else.

        Five conditions, all of them load-bearing:

        - **Session principals only.** An API key is never eligible, whatever it is scoped to. A
          machine credential that could enumerate every customer is a far larger blast radius
          than a human session.
        - **`is_platform_admin` required.** This is not a tenant role. No tenant `owner` or
          `admin` holds it and no tenant can grant it.
        - **Read only.** Listing, and only these two collections. There is no cross-tenant write
          path anywhere, and no cross-tenant read of invoices, certificates or users.
        - **Opt in per request.** Omitted, a platform admin gets the ordinary tenant-scoped list.
        - **Audited.** Every cross-tenant read is recorded against the operator who made it.

        For anyone who does not meet the conditions the parameter is **ignored, not rejected**:
        the response is the ordinary tenant-scoped list, with no error to probe for and no
        difference in shape. Tenant users are entirely unaffected — with or without this
        parameter they see only their own tenant.

        Rows are the same objects as a tenant-scoped listing, with no added tenant field.
      schema: { type: boolean, default: false }

    InvoiceId:
      name: id
      in: path
      required: true
      description: The invoice id returned by `POST /v1/invoices`.
      schema:
        type: string
      example: inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4

    ResourceId:
      name: id
      in: path
      required: true
      description: The resource id.
      schema:
        type: string
        format: uuid
      example: 3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57

  headers:
    IdempotentReplayed:
      description: >-
        `true` when this response was replayed from the idempotency store rather than produced
        by a fresh fiscalization. Absent otherwise.
      schema:
        type: boolean
      example: true
    RateLimitLimit:
      description: Requests permitted in the current one-minute window for this principal.
      schema: { type: integer }
      example: 20000
    RateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer }
      example: 19994
    RateLimitReset:
      description: Seconds until the current window resets.
      schema: { type: integer }
      example: 27
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }
      example: 27
    XRequestId:
      description: The correlation id for this request — yours if you sent one, otherwise generated.
      schema: { type: string }
      example: req-7f3c9a21

  responses:
    BadRequest:
      description: |
        The request itself is wrong and will keep failing until the caller changes it —
        malformed body, a restrictive error caught locally, a device with no ISU number, a
        company with no certificate. Never retried automatically.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            invalidRequest:
              summary: A field failed validation
              value:
                error:
                  type: validation_error
                  code: invalid_request
                  message: 'total must be a decimal with exactly 2 places, e.g. "12.00". Received "12.5".'
                  param: total
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/invalid_request
            missingIdempotencyKey:
              summary: The header was omitted
              value:
                error:
                  type: validation_error
                  code: missing_idempotency_key
                  message: >-
                    POST /v1/invoices requires an Idempotency-Key header. Generate one UUID per
                    sale and reuse it across retries of that sale.
                  param: Idempotency-Key
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/missing_idempotency_key
            deviceMissingIsu:
              summary: The device has no ISU number
              value:
                error:
                  type: validation_error
                  code: hr.device_missing_isu
                  message: >-
                    Device VM-0043 has no ISU number assigned. Register the premises in ePorezna and
                    add the ISU number before fiscalizing.
                  param: device_id
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/hr.device_missing_isu
            saleTooOld:
              summary: Past the 30-day cliff, caught before the network
              value:
                error:
                  type: validation_error
                  code: hr.sale_older_than_30_days
                  message: >-
                    occurred_at is 2026-07-01T09:12:30+02:00, which is 57 days ago. Croatian
                    fiscalization rejects sales older than 30 days (restrictive error 176) and this
                    cannot be worked around.
                  param: occurred_at
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/hr.sale_older_than_30_days
            invalidReceiptNumber:
              summary: A supplied receipt number in a form the Tax Administration rejects
              value:
                error:
                  type: validation_error
                  code: hr.invalid_receipt_number
                  message: >-
                    Receipt number "007" must be digits with no leading zeros and cannot be 0
                    (restrictive error 178). Send it as a string, or omit it and FiskHub
                    allocates the next one for this selling point.
                  param: number
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/hr.invalid_receipt_number
            invalidVatRate:
              summary: A VAT rate outside the permitted set
              value:
                error:
                  type: validation_error
                  code: hr.invalid_vat_rate
                  message: 'VAT rate "22.00" is not permitted. Croatian VAT rates are 0.00, 5.00, 13.00 and 25.00 (restrictive error 182).'
                  param: vat.0.rate
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/hr.invalid_vat_rate

    Unauthorized:
      description: The API key is missing, malformed, unknown or revoked.
      headers:
        WWW-Authenticate:
          description: Always `Bearer`.
          schema: { type: string }
          example: Bearer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            invalidApiKey:
              value:
                error:
                  type: authentication_error
                  code: invalid_api_key
                  message: >-
                    No API key was recognised. Send `Authorization: Bearer fh_live_…` or
                    `Authorization: Bearer fh_test_…`.
                  param: Authorization
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/invalid_api_key
            revoked:
              value:
                error:
                  type: authentication_error
                  code: api_key_revoked
                  message: This API key was revoked on 2026-08-01. Issue a new key in the dashboard.
                  param: Authorization
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/api_key_revoked

    Forbidden:
      description: |
        The key is valid but not allowed to do this — wrong mode for the target company, or a
        scope the key does not hold.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            modeMismatch:
              value:
                error:
                  type: authorization_error
                  code: mode_mismatch
                  message: >-
                    This is a test key (fh_test_…) but company AWAIT 8 J.D.O.O. is in live mode. Test
                    keys can never reach production tax endpoints.
                  param: device_id
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/mode_mismatch
            insufficientScope:
              value:
                error:
                  type: authorization_error
                  code: insufficient_scope
                  message: "This key holds scopes [invoices:read] and cannot write invoices. Required scope: invoices:write."
                  param: null
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/insufficient_scope

    NotFound:
      description: No such resource is visible to this API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            notFound:
              value:
                error:
                  type: not_found
                  code: invalid_request
                  message: No invoice with id inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4.
                  param: id
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/invalid_request

    InvoiceConflict:
      description: |
        The request conflicts with something already recorded. Two distinct causes, told apart by
        `code`:

        - `idempotency_key_reused` — the `Idempotency-Key` was used within the last 24 hours with
          a **different** request body. FiskHub will not guess which one you meant.
        - `invoice_already_exists` — a supplied `number` is already filed for this selling point,
          device and year. The business key is what makes a receipt unique, and a sale is never
          fiscalized twice.

        A replay with the same key **and** the same body is neither: it returns the original
        `200` with `Idempotent-Replayed: true`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            reused:
              summary: The same key, a different body
              value:
                error:
                  type: conflict
                  code: idempotency_key_reused
                  message: >-
                    Idempotency-Key 8f0b4e2a-6c31-4d9f-9a75-2b8e1c0d47f3 was already used for a
                    different request body. Use a new key for a new sale, and the original key only
                    for retries of the original sale.
                  param: Idempotency-Key
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/idempotency_key_reused
            receiptNumberTaken:
              summary: The supplied receipt number is already filed at this selling point
              description: |
                Only reachable when the caller supplies `number`. Advance your counter past the
                highest number filed at that selling point this year, or omit `number` and let
                FiskHub allocate it.
              value:
                error:
                  type: conflict
                  code: invoice_already_exists
                  message: >-
                    Receipt 1042 at selling point POSL1 has already been submitted for this device
                    and year. It is confirmed with JIR
                    8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14. A sale is never fiscalized twice.
                  param: number
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/invoice_already_exists

    RateLimited:
      description: |
        Too many requests in the window. Nothing was processed, so this is safe to retry with the
        **same** `Idempotency-Key`.

        Requests are counted per principal and per IP, both at 20,000 per minute — high enough
        that ordinary trading never meets them. A `429` on `POST /v1/invoices` almost always means
        something is looping without backoff. Sign-in is held far tighter, at
        30 per 15 minutes per IP, and upload endpoints at 60 per minute.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            rateLimited:
              value:
                error:
                  type: rate_limit_error
                  code: rate_limited
                  message: Too many requests. Slow down and retry; see the Retry-After header.
                  param: null
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/rate_limited

    InternalError:
      description: |
        Something broke on our side. Safe to retry with the **same** `Idempotency-Key` — that
        is exactly what it is for.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorBody"
          examples:
            internal:
              value:
                error:
                  type: internal_error
                  code: internal_error
                  message: An unexpected error occurred. Retry with the same Idempotency-Key; quote request id req-7f3c9a21 if it persists.
                  param: null
                  upstream: null
                  doc_url: https://docs.fiskhub.com/errors/internal_error

  schemas:
    Decimal2:
      type: string
      pattern: '^-?\d{1,13}\.\d{2}$'
      description: |
        A monetary amount as a **string** with exactly two decimal places and a dot separator —
        `Decimal(15,2)`, which is what the Tax Administration expects. Never send a JSON number:
        `4.5` and `4.50` are different strings to the ZKI hash, and binary floating point cannot
        represent cents exactly.
      examples: ["4.50", "1525.99", "-105.50", "0.00"]

    Oib:
      type: string
      pattern: '^[0-9]{11}$'
      description: Croatian personal identification number — 11 digits with an ISO 7064 MOD 11,10 check digit.
      examples: ["25089460814"]

    CountryCode:
      type: string
      enum: [HR]
      description: The only fiscalization provider implemented is Croatia.

    FiscalMode:
      type: string
      enum: [test, live]
      description: |
        `test` uses `cistest.apis-it.hr` with demo certificates and produces JIRs with no legal
        effect. `live` uses `cis.porezna-uprava.hr` and produces real fiscal records.

    EntityStatus:
      type: string
      enum: [active, suspended, archived]

    IsuStatus:
      type: string
      enum: [pending, assigned, mismatch]
      description: |
        - `pending` — no ISU number yet. The device **cannot fiscalize**.
        - `assigned` — an ISU number is set and used as `OznNapUr`.
        - `mismatch` — the Tax Administration rejected the ISU number we hold. Re-check it in
          ePorezna; the device cannot fiscalize until it is corrected.

    CertificateStatus:
      type: string
      enum: [valid, expiring, expired, superseded, revoked]
      description: |
        `expiring` means less than 60 days remain. When a certificate reaches `expired`, every
        device of that company stops being able to fiscalize.

    PaymentMethod:
      type: string
      enum: [G, K, O, T]
      description: |
        `NacinPlac`. `G` cash (*gotovina*), `K` card (*kartica*), `T` bank transfer
        (*transakcijski račun*), `O` other, which also covers a sale split across several
        methods.

        **`T` depends on `sale_type`.** An attended receipt (§2.1.1) may report all four. An
        unattended sale may report only `G`, `K` or `O` — there is nobody at a self-service
        device to take a bank transfer — and `T` is rejected with `hr.invalid_payment_method`.

        `T` also may not be combined with `recipient_oib` on an attended receipt: restrictive
        error **181**.

    SaleType:
      type: string
      enum: [unattended, attended]
      default: unattended
      description: |
        Which set of message rules the sale follows.

        - `unattended` — a self-service device (spec §2.2). The default, and the only kind of
          sale FiskHub accepted before this field existed.
        - `attended` — a receipt issued by an operator (§2.1).

        A caller that has never sent this field keeps exactly the behaviour it had.

    Oib11:
      type: string
      pattern: '^[0-9]{11}$'
      description: |
        An OIB as the schema sees it: eleven digits. The ISO 7064 check digit is **not** enforced
        at the schema level, deliberately — a bad check digit on a recipient OIB comes back as
        restrictive error **180**, with that upstream code attached, rather than as a generic
        schema failure.
      examples: ["25089460814"]

    NamedTaxLine:
      type: object
      required: [rate, base, amount]
      additionalProperties: false
      description: |
        A tax line that may carry a name. `OstaliPor/Porez` requires `NazivPor` once the group is
        present (§2.1.1); it is optional here and defaulted downstream, so an existing caller
        sending an unnamed line still produces a schema-valid message.
      properties:
        rate:
          $ref: "#/components/schemas/Decimal2"
          description: 'Percentage as a decimal string, e.g. "3.00".'
        base:
          $ref: "#/components/schemas/Decimal2"
        amount:
          $ref: "#/components/schemas/Decimal2"
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: '`NazivPor` — what this tax is called, e.g. "Porez na luksuz".'
      examples:
        - rate: "5.00"
          base: "40.00"
          amount: "2.00"
          name: Porez na luksuz

    InvoiceStatus:
      type: string
      enum: [pending, confirmed, retrying, failed, expired]
      description: |
        | Value | Meaning | Terminal | Retried automatically |
        |---|---|---|---|
        | `pending` | Persisted, not yet attempted. Exists only mid-request; you will not see it in a response. | no | no |
        | `confirmed` | JIR received and its signature verified. | **yes** | no |
        | `retrying` | Transient failure. The sweep keeps trying until the 30-day deadline. | no | **yes** |
        | `failed` | Validation or certificate error. Needs a human, then `POST /v1/invoices/:id/retry`. | no | no |
        | `expired` | Past the 30-day deadline. Permanently unfiscalizable. | **yes** | no |

        In every one of these states the sale itself is valid and the receipt printed from the
        ZKI is compliant.

    FiscalErrorClass:
      type: string
      enum: [transient, validation, certificate]
      description: |
        Why the fiscalization failed, and therefore whether retrying can help. Only `transient`
        is retried automatically.

    TaxLine:
      type: object
      required: [rate, base, amount]
      additionalProperties: false
      properties:
        rate:
          $ref: "#/components/schemas/Decimal2"
          description: 'Percentage as a decimal string, e.g. "25.00".'
        base:
          $ref: "#/components/schemas/Decimal2"
          description: The taxable base this rate applies to.
        amount:
          $ref: "#/components/schemas/Decimal2"
          description: The tax charged on that base.
      examples:
        - rate: "25.00"
          base: "3.60"
          amount: "0.90"

    Fee:
      type: object
      required: [name, amount]
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: '`NazivN` — e.g. "Povratna naknada".'
        amount:
          $ref: "#/components/schemas/Decimal2"
      examples:
        - name: Povratna naknada
          amount: "2.50"

    CreateInvoiceRequest:
      type: object
      required: [device_id, occurred_at, total, payment_method]
      additionalProperties: false
      description: |
        The sale, as the device saw it.

        Every element of the Croatian `RacunZahtjev` that is not fleet identity can be supplied
        here. `Oib` and `OznPosPr` cannot: the OIB must equal the signing certificate's or the
        Tax Administration answers `s005`, and the premises code is part of the invoice's
        uniqueness key. Both come from the resolved company and device.

        Only `device_id`, `occurred_at`, `total` and `payment_method` are required.

        ## Omitted is not the same as `null`

        Optional fields accept `null`, and the two states mean different things:

        - **absent** — "I did not say." The value is resolved down a chain:
          **request → the device's `device_config` → the company's `fiscal_config` → the
          specification default.**
        - **`null`** — "explicitly none." The element is omitted from the message and no
          configured default is consulted.

        Without that distinction a device-level default silently reappears on the one sale that
        meant to clear it. A `null` inside `device_config` masks `fiscal_config` the same way.

        Config keys use the same `snake_case` spelling as these fields, so what an operator types
        into a device's configuration is literally the field name they would otherwise have sent.
        A configured value of the wrong type is ignored, not coerced — a number where a string
        belongs must never reach a signed fiscal message.

        ## Attended and unattended

        `sale_type` selects the rules. The fields grouped under *attended receipts* below belong
        to the §2.1 message only and are rejected on an unattended sale rather than silently
        dropped, because a caller who sends them has misunderstood something.
      properties:
        device_id:
          type: string
          format: uuid
          description: |
            The device that made the sale. It determines the company (and therefore the OIB and
            certificate), the premises code (`OznPosPr`) and the ISU number (`OznNapUr`).
        occurred_at:
          type: string
          format: date-time
          description: |
            **The moment of the sale, not the moment of this request.** ISO 8601 with an explicit
            offset. It feeds both the ZKI and `DatVrijeme`, and it starts the 30-day
            fiscalization clock.

            A device that has been offline sends the original sale time here; FiskHub marks the
            submission as late delivery (`NakDost`) automatically.
          examples: ["2026-08-27T10:32:11+02:00"]
        total:
          $ref: "#/components/schemas/Decimal2"
          description: '`IznosUkupno` — the amount the customer paid, including all taxes and fees.'
        currency:
          type: string
          minLength: 3
          maxLength: 3
          default: EUR
          description: ISO 4217. Croatian fiscalization is euro-denominated; `EUR` is the only accepted value.
          examples: ["EUR"]
        payment_method:
          $ref: "#/components/schemas/PaymentMethod"
        number:
          type: string
          pattern: '^[1-9][0-9]{0,19}$'
          description: |
            `BrOznRac` — the receipt number, as a **string** of digits with no leading zeros.
            `0` and `007` are rejected (restrictive error 178).

            Omit it and FiskHub allocates the next number for this selling point and calendar
            year. Supply it and it is used verbatim, which is what you want when your own system
            is the book of record for receipt numbering.

            The sequence belongs to the **business premises**, not to your account: with
            `OznSlijed = P` two selling points both having a receipt `1` is correct. Keep one
            counter per selling point, or send nothing and let FiskHub keep it.
          examples: ["1042"]
        sale_type:
          $ref: "#/components/schemas/SaleType"
        vat:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/TaxLine"
          description: |
            `Pdv` — one entry per rate. Rates must be 0.00, 5.00, 13.00 or 25.00 (restrictive
            error 182). Send an empty array, or `null`, for a company that is not VAT-registered.

            **Omitting the field** falls back to the company's `default_vat_rate` (or the
            device's, which wins): one `<Porez>` is derived from `total`, treating the total as
            VAT-inclusive — base = total x 10000 / (10000 + rate x 100), rounded half-up, and
            amount = total - base. The fallback only runs for a VAT-registered issuer and only
            when no `default_vat_rate` is configured does the receipt go out without `<Pdv>`.

            The fallback can only ever describe **one** rate, because the request carries no
            line items — just `total`. A sale spanning two rates must send `vat` explicitly,
            one entry per rate; otherwise the whole total is filed at the default rate and the
            split is wrong. `null` is the third case: it means "this sale has no VAT" and
            suppresses both the fallback and the element.
        consumption_tax:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/TaxLine"
          description: '`Pnp` — porez na potrošnju. Rate between 0.00 and 3.00 (restrictive errors 183 and 184).'
        other_taxes:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/NamedTaxLine"
          description: "`OstaliPor` — any other tax that applies. Each line may carry a `name` (`NazivPor`)."
        vat_exempt_amount:
          oneOf:
            - $ref: "#/components/schemas/Decimal2"
            - type: "null"
          description: "`IznosOslobPdv` — the portion exempt from VAT."
        margin_amount:
          oneOf:
            - $ref: "#/components/schemas/Decimal2"
            - type: "null"
          description: "`IznosMarza` — the portion under the margin scheme."
        non_taxable_amount:
          oneOf:
            - $ref: "#/components/schemas/Decimal2"
            - type: "null"
          description: "`IznosNePodlOpor` — the portion not subject to taxation."
        fees:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/Fee"
          description: "`Naknade` — deposits and similar charges included in `total`."
        special_purpose:
          type: [string, "null"]
          maxLength: 1000
          description: "`SpecNamj` — free text carried through to the Tax Administration. Useful for your own machine or route reference."
        late_delivery:
          type: [boolean, "null"]
          description: |
            `NakDost` — this receipt is being reported after the fact. Set automatically on every
            re-send, so a device that has been offline does not have to compute it. Sending
            `true` forces it on the first attempt as well, which is what a till copying up a
            backlog wants.
        vat_registered:
          type: [boolean, "null"]
          description: |
            `USustPdv` — whether the issuer is inside the VAT system. Defaults to the company's
            own registration flag; override it only when this receipt genuinely differs.

        # --- attended receipts (§2.1) ------------------------------------------------------
        # Rejected on an unattended sale rather than ignored: a caller who sends them has
        # misunderstood which message they are building.

        sequence_mark:
          type: [string, "null"]
          enum: [P, N, null]
          description: |
            `OznSlijed` — how receipts are numbered. `P` per business premises, `N` per device.

            **Attended receipts only.** An unattended sale is numbered per business premises,
            full stop (§2.2.1), so `P` is not a default there but the only legal value; sending
            `N` is rejected rather than quietly corrected.

            Either level can be allocated for you: omit `number` and FiskHub counts within
            whichever level this field declares — every sale at the premises for `P`, one
            register's sales for `N`. Two registers under `N` therefore both start at 1, which
            is what `N` means.

            **One premises runs one level per calendar year.** Because `OznSlijed` travels on
            each receipt, nothing in the protocol stops a premises filing some sales `P` and
            others `N` — and two allocated sequences over one premises would both reach 1, 2, 3
            with no error anywhere. The second level is refused with `400` naming this field.
            The year is the natural boundary: numbering restarts on 1 January anyway.
        register_mark:
          type: [string, "null"]
          pattern: '^[1-9][0-9]{0,19}$'
          description: |
            `BrRac/OznNapUr` for an attended receipt: the **cash register's mark**, which is not
            an ISU number and is not issued by anyone — it is yours to assign, unique within the
            business premises.

            **Attended receipts only.** An unattended sale always reports the device's ISU number
            in this element, so `register_mark` is rejected there; record the ISU number against
            the device instead.
          examples: ["1"]
        operator_oib:
          oneOf:
            - $ref: "#/components/schemas/Oib11"
            - type: "null"
          description: |
            `OibOper` — the OIB of the **operator who issued the receipt**.

            **Attended receipts only.** There is no cashier at a self-service device, so an
            unattended sale reports the company's own OIB (§2.2.1) and any other value is
            rejected. Restrictive error **179** applies to it when it differs from the company's.
        recipient_oib:
          oneOf:
            - $ref: "#/components/schemas/Oib11"
            - type: "null"
          description: |
            `OibPrimateljaRacuna` — the buyer's OIB, for a B2B receipt.

            **Attended receipts only.** The unattended message does not contain this element at
            all (§2.2.1). Restrictive error **180** checks its ISO 7064 check digit, and **181**
            forbids combining it with `payment_method` `T` — a recipient OIB is only for B2B
            receipts settled in cash or by card.
        paragon_number:
          type: [string, "null"]
          maxLength: 100
          description: |
            `ParagonBrRac` — the number of a hand-written receipt being copied up after a device
            failure.

            **Attended receipts only.** It is explicitly unused for sales through a self-service
            device (§2.2.1) and is rejected there.

    FiscalError:
      type: object
      required: [code, message, retry_scheduled]
      description: |
        Present on an invoice that has no JIR. **It does not invalidate the sale** — the ZKI and
        the QR payload alongside it are complete and the receipt is compliant.
      properties:
        code:
          type: string
          description: A stable code from the error taxonomy. Resolve it at `https://docs.fiskhub.com/errors/<code>`.
          examples: [upstream_unavailable]
        message:
          type: string
          description: Human-readable and actionable.
        retry_scheduled:
          type: boolean
          description: |
            `true` when FiskHub will keep trying on its own (the failure was transient).
            `false` when a human has to change something first — a certificate, an ISU number, a
            rate — after which `POST /v1/invoices/:id/retry` submits it again.

    InvoiceResponse:
      type: object
      required: [id, status, jir, zki, qr_payload, number, occurred_at, total, currency]
      description: |
        The result of a fiscalization attempt, returned by `POST /v1/invoices` and
        `POST /v1/invoices/{id}/retry`.

        `zki` and `qr_payload` are **always** present — they are computed locally and never
        depend on the network. `jir` is present only when the Tax Administration confirmed the
        receipt and its response signature verified.
      properties:
        id:
          type: string
          description: The FiskHub invoice id. Stable, and the handle for retrieval and retry.
          examples: [inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4]
        status:
          type: string
          enum: [confirmed, retrying, failed]
          description: |
            The outcome of this attempt. `pending` and `expired` never appear here — the first
            exists only inside the request, the second only on an invoice that has aged out.

            **All three values mean the sale is valid.** Only `confirmed` carries a JIR.
        jir:
          type: [string, "null"]
          format: uuid
          description: |
            *Jedinstveni identifikator računa* — the Tax Administration's receipt identifier.

            **`null` is not an error.** It means the receipt has not been confirmed *yet*. Print
            the ZKI-based `qr_payload` and complete the sale.
          examples: ["2cf55235-9470-4b5c-a539-463f52b109d2"]
        zki:
          type: string
          pattern: '^[0-9a-f]{32}$'
          description: |
            *Zaštitni kod izdavatelja* — the issuer protection code. 32 lowercase hex characters,
            computed locally from the company OIB, sale datetime, receipt number, premises code,
            ISU number and total. Always present, even when the Tax Administration is unreachable.
            It must be printed on the receipt.
          examples: [e4d909c290d0fb1ca068ffaddf22cbd0]
        qr_payload:
          type: string
          format: uri
          description: |
            The exact string to encode in the receipt's QR code — encode it verbatim, do not
            re-order or re-encode the parameters. It carries `jir=` when a JIR exists and `zki=`
            when it does not; both forms are compliant.

            Display requirements: at least 2×2 cm, at least a 2 mm quiet zone, error correction
            level L or better, no logo overlay.
          examples:
            - https://porezna.gov.hr/rn?jir=2cf55235-9470-4b5c-a539-463f52b109d2&datv=20260827_1032&izn=450
            - https://porezna.gov.hr/rn?zki=e4d909c290d0fb1ca068ffaddf22cbd0&datv=20260827_1032&izn=450
        number:
          type: string
          description: '`BrOznRac` — the receipt number FiskHub allocated, digits with no leading zeros. Print it on the receipt.'
          examples: ["1041"]
        occurred_at:
          type: string
          format: date-time
          description: The sale moment, echoed back as sent.
        total:
          $ref: "#/components/schemas/Decimal2"
        currency:
          type: string
          examples: ["EUR"]
        fiscal_error:
          $ref: "#/components/schemas/FiscalError"

    Invoice:
      type: object
      required:
        [
          id,
          status,
          jir,
          zki,
          qr_payload,
          number,
          occurred_at,
          total,
          currency,
          company_id,
          device_id,
          payment_method,
          mode,
          attempts,
          late_delivery,
          deadline_at,
          created_at,
        ]
      description: |
        The stored invoice, as returned by `GET /v1/invoices` and `GET /v1/invoices/{id}`. Every
        field of `InvoiceResponse` plus the record's own metadata, and the full five-value
        status vocabulary.
      properties:
        id:
          type: string
          examples: [inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4]
        status:
          $ref: "#/components/schemas/InvoiceStatus"
        jir:
          type: [string, "null"]
          format: uuid
        zki:
          type: string
          pattern: '^[0-9a-f]{32}$'
        qr_payload:
          type: string
          format: uri
        number:
          type: string
        occurred_at:
          type: string
          format: date-time
        total:
          $ref: "#/components/schemas/Decimal2"
        currency:
          type: string
        company_id:
          type: string
          format: uuid
        device_id:
          type: string
          format: uuid
        payment_method:
          $ref: "#/components/schemas/PaymentMethod"
        mode:
          $ref: "#/components/schemas/FiscalMode"
        attempts:
          type: integer
          minimum: 0
          description: How many times this invoice has been sent to the Tax Administration.
        late_delivery:
          type: boolean
          description: '`NakDost` — true once the invoice has been submitted after its original sale moment.'
        deadline_at:
          type: string
          format: date-time
          description: |
            `occurred_at` plus 30 days. After this instant the Tax Administration rejects the
            receipt permanently (restrictive error 176) and the invoice becomes `expired`.
        confirmed_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        last_error_class:
          oneOf:
            - $ref: "#/components/schemas/FiscalErrorClass"
            - type: "null"
          description: |
            The class of the most recent failure, persisted alongside the code so the retry
            sweep can filter without re-parsing error strings. `null` on an invoice that has
            never failed.
        fiscal_error:
          $ref: "#/components/schemas/FiscalError"

    FiscalMessage:
      type: object
      required:
        [id, message_uuid, attempt_no, http_status, error_code, error_message, duration_ms, created_at, request_xml, response_xml]
      description: |
        One exchange with the Tax Administration, as stored in `fiscal_messages`.

        The table is **append-only**: rows are never updated and never deleted. This is the
        compliance audit trail — what was submitted and what came back, preserved so it can be
        shown to an auditor — rather than a source of operational metrics.
      properties:
        id:
          type: string
          format: uuid
        message_uuid:
          type: string
          format: uuid
          description: |
            `<Zaglavlje><IdPoruke>` for this attempt. **A fresh UUID on every attempt**, retries
            of the same sale included — the specification requires it, and it is how one exchange
            is identified upstream. It is not an identifier for the sale.
        attempt_no:
          type: integer
          minimum: 1
          description: 1 for the original submission, incrementing for each retry.
        http_status:
          type: [integer, "null"]
          description: The transport status, or `null` when no answer came back at all.
        error_code:
          type: [string, "null"]
          description: The classified failure for this attempt, from the error taxonomy, or `null`.
        error_message:
          type: [string, "null"]
        duration_ms:
          type: [integer, "null"]
          description: |
            Wall-clock time of this single exchange. Useful for explaining one slow sale; not a
            monitoring feed.
        created_at:
          type: string
          format: date-time
        request_xml:
          type: [string, "null"]
          description: The signed `RacunZahtjev` exactly as sent.
        response_xml:
          type: [string, "null"]
          description: The `RacunOdgovor` exactly as received. `null` when the request timed out.

    InvoiceFiledPayload:
      type: object
      description: |
        The business data as it was **filed**, in request shape. Sent by
        `GET /v1/invoices/{id}` only.

        This is not an echo of the request. Everything a fiscal message carries can come from
        the request, the device's configuration, the company's, or the specification default —
        and what is recorded here is the value that chain produced, at the moment the receipt
        was sealed. A sale that omitted `vat` and took the company's `default_vat_rate` carries
        the derived VAT lines; a sale that omitted `special_purpose` and had none configured
        carries `null`.

        Recording the resolved values rather than the raw request is also what makes a retry
        safe: the sweep re-sends from this object, so editing a company's configuration cannot
        change the message filed for a sale that already went out under the old one.
      additionalProperties: false
      properties:
        sale_type:
          $ref: "#/components/schemas/SaleType"
        number:
          type: string
        occurred_at:
          type: string
          format: date-time
        total:
          type: string
        currency:
          type: string
        payment_method:
          $ref: "#/components/schemas/PaymentMethod"
        vat:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/TaxLine"
        consumption_tax:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/TaxLine"
        other_taxes:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/NamedTaxLine"
        vat_exempt_amount:
          type: [string, "null"]
        margin_amount:
          type: [string, "null"]
        non_taxable_amount:
          type: [string, "null"]
        fees:
          type: [array, "null"]
          items:
            type: object
            required: [name, amount]
            properties:
              name: { type: string }
              amount: { type: string }
        special_purpose:
          type: [string, "null"]
        late_delivery:
          type: [boolean, "null"]
        vat_registered:
          type: [boolean, "null"]
        sequence_mark:
          type: [string, "null"]
          enum: ["P", "N", null]
          description: Attended receipts only — an unattended sale is always `P` (§2.2.1).
        register_mark:
          type: [string, "null"]
          description: |
            Attended receipts only. Absent on an unattended sale, whose `OznNapUr` is the
            device's ISU number rather than anything the message carried.
        operator_oib:
          type: [string, "null"]
          description: Attended receipts only — an unattended sale reports the company's OIB.
        recipient_oib:
          type: [string, "null"]
          description: Attended receipts only — not part of the unattended message at all.
        paragon_number:
          type: [string, "null"]
          description: Attended receipts only.

    InvoiceWithMessages:
      allOf:
        - $ref: "#/components/schemas/Invoice"
        - type: object
          properties:
            business_key:
              type: [string, "null"]
              description: |
                `oib|OznPosPr|OznNapUr|BrOznRac|year` — the natural key of a receipt, and what
                makes a second submission of one sale impossible. Sent on this endpoint only;
                `GET /v1/invoices` omits it.
              examples: ["25089460814|POSL1|1001|1042|2026"]
            payload:
              $ref: "#/components/schemas/InvoiceFiledPayload"
            messages:
              type: array
              items:
                $ref: "#/components/schemas/FiscalMessage"
              description: |
                Present **only** when the request carried `include_messages=true`. Oldest attempt
                first. Without the parameter the response has no `messages` key at all.

    AttemptWithMessages:
      allOf:
        - $ref: "#/components/schemas/InvoiceResponse"
        - type: object
          properties:
            messages:
              type: array
              items:
                $ref: "#/components/schemas/FiscalMessage"
              description: |
                Present **only** when the request carried `include_messages=true`. Every attempt
                made on this invoice, oldest first — on a retry that includes the earlier failed
                sends, so the XML that succeeded can be compared with the ones that did not.
                Without the parameter the response has no `messages` key at all.

    InvoiceList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object:
          type: string
          const: list
        data:
          type: array
          items:
            $ref: "#/components/schemas/Invoice"
        has_more:
          type: boolean
        next_cursor:
          type: [string, "null"]
          description: Pass as `cursor` to fetch the next page. `null` on the last page.

    CreateCompanyRequest:
      type: object
      required: [name, oib]
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        oib:
          $ref: "#/components/schemas/Oib"
          description: The company OIB. It must match the signing certificate's subject OIB, or the Tax Administration returns `s005`.
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          default: HR
          description: |
            ISO 3166-1 alpha-2. Selects the fiscalization provider for everything this company
            files. A taxpayer files in exactly one country, so it lives here rather than on the
            device.
          examples: [HR]
        vat_registered:
          type: boolean
          default: true
          description: '`USustPdv` — whether the company is in the VAT system.'
        address:
          type: object
          default: {}
          additionalProperties: true
        fiscal_config:
          type: object
          default: {}
          additionalProperties: true
          description: Country-specific defaults — sequence mode, tax defaults, endpoint override.
        mode:
          $ref: "#/components/schemas/FiscalMode"
          default: test

    UpdateCompanyRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: '`oib` is absent on purpose — it cannot be changed.'
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        vat_registered: { type: boolean }
        address: { type: object, additionalProperties: true }
        fiscal_config: { type: object, additionalProperties: true }
        mode:
          $ref: "#/components/schemas/FiscalMode"
        status:
          $ref: "#/components/schemas/EntityStatus"

    Company:
      type: object
      required: [id, tenant_id, name, oib, country_code, vat_registered, address, fiscal_config, mode, status, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id:
          type: string
          format: uuid
          description: >-
            The owning tenant. Always present, not only on a cross-tenant listing:
            the dashboard tells its own rows from another tenant's by comparing this
            to the signed-in tenant.
        name: { type: string }
        oib:
          $ref: "#/components/schemas/Oib"
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2. Selects the fiscalization provider for this company.
          examples: [HR]
        vat_registered: { type: boolean }
        address: { type: object, additionalProperties: true }
        fiscal_config: { type: object, additionalProperties: true }
        mode:
          $ref: "#/components/schemas/FiscalMode"
        status:
          $ref: "#/components/schemas/EntityStatus"
        certificate_status:
          type: string
          enum: [missing, valid, expiring, expired]
          description: The state of this company's signing certificate. `missing` or `expired` means none of its devices can fiscalize.
        certificate_expires_at:
          type: [string, "null"]
          format: date-time
        created_at: { type: string, format: date-time }

    CompanyList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items:
            $ref: "#/components/schemas/Company"
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    CreateDeviceRequest:
      type: object
      required: [company_id, premises_code, label]
      additionalProperties: false
      properties:
        company_id: { type: string, format: uuid }
        premises_code:
          type: string
          pattern: '^[0-9a-zA-Z]{1,20}$'
          maxLength: 20
          description: |
            The business premises code (`OznPosPr`), exactly as registered in ePorezna.

            It is part of every receipt's business key and of its ZKI, and it is the key
            receipt numbering runs on. It cannot be changed after the device is created.
          examples: [POSL1]
        premises_address:
          type: [string, "null"]
          maxLength: 500
          description: Free text, for your own dashboards. Never sent to the Tax Administration.
          examples: ["Ilica 1, 10000 Zagreb"]
        label:
          type: string
          minLength: 1
          maxLength: 200
          description: Your name for the device. Unique within the company, and the match key for CSV import.
          examples: [VM-0042]
        isu_number:
          type: string
          pattern: '^[1-9][0-9]{0,19}$'
          description: |
            The self-service device identifier (`OznNapUr`), digits with no leading zeros.

            **Assigned by the Tax Administration when the business premises is registered in
            ePorezna.** FiskHub cannot generate, request or look one up — premises registration
            has never been available over the fiscalization web service. Omit it if you do not
            have it yet; the device is created with `isu_status: "pending"` and cannot fiscalize
            until one is entered.
          examples: ["42"]
        device_config:
          type: object
          default: {}
          additionalProperties: true

    UpdateDeviceRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        `company_id` and `premises_code` are absent on purpose. A device belongs to the fiscal
        identity that signs for it, and `premises_code` is `OznPosPr` — changing it would change
        the business key and the ZKI of every future receipt and move the device into a different
        receipt-number sequence. Archive the device and register a new one at the new premises.
      properties:
        label: { type: string, minLength: 1, maxLength: 200 }
        premises_address: { type: [string, "null"], maxLength: 500 }
        isu_number:
          type: [string, "null"]
          pattern: '^[1-9][0-9]{0,19}$'
          description: Setting a value moves `isu_status` to `assigned`; setting `null` moves it back to `pending` and stops the device fiscalizing.
        device_config: { type: object, additionalProperties: true }
        status:
          $ref: "#/components/schemas/EntityStatus"

    Device:
      type: object
      required: [id, tenant_id, company_id, premises_code, label, isu_number, isu_status, device_config, status, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id:
          type: string
          format: uuid
          description: The owning tenant. See `Company.tenant_id`.
        company_id: { type: string, format: uuid }
        premises_code: { type: string, maxLength: 20 }
        premises_address: { type: [string, "null"], maxLength: 500 }
        label: { type: string }
        isu_number: { type: [string, "null"] }
        isu_status:
          $ref: "#/components/schemas/IsuStatus"
        device_config: { type: object, additionalProperties: true }
        status:
          $ref: "#/components/schemas/EntityStatus"
        installed_at: { type: [string, "null"], format: date-time }
        last_seen_at:
          type: [string, "null"]
          format: date-time
          description: The last time this device called `POST /v1/invoices`, successful or not.
        last_success_at:
          type: [string, "null"]
          format: date-time
          description: The last time a sale from this device was confirmed with a JIR.
        created_at: { type: string, format: date-time }

    DeviceList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items:
            $ref: "#/components/schemas/Device"
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    DeviceImportRowError:
      type: object
      required: [row, code, message]
      properties:
        row:
          type: integer
          description: The line number in the uploaded file, counting the header as line 1.
        label: { type: [string, "null"] }
        code:
          type: string
          description: A code from the error taxonomy.
        message: { type: string }
        param: { type: [string, "null"], description: The CSV column at fault. }

    DeviceImportResult:
      type: object
      required: [object, dry_run, processed, created, updated, failed, errors]
      properties:
        object: { type: string, const: device_import }
        dry_run: { type: boolean }
        processed:
          type: integer
          description: Data rows read, excluding the header.
        created: { type: integer }
        updated: { type: integer }
        failed: { type: integer }
        errors:
          type: array
          items:
            $ref: "#/components/schemas/DeviceImportRowError"

    Certificate:
      type: object
      required:
        [id, company_id, subject_oib, issuer, serial, not_before, not_after, days_until_expiry, status, uploaded_at]
      description: |
        Certificate **metadata**. No field of this object contains key material, and no endpoint
        returns key material.
      properties:
        id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        label: { type: [string, "null"] }
        subject_oib:
          $ref: "#/components/schemas/Oib"
          description: The OIB read from the certificate subject. It must equal the company's OIB.
        subject_cn: { type: [string, "null"], examples: ["FISKAL 1"] }
        issuer: { type: string, examples: ["Fina Demo CA 2020"] }
        serial: { type: string }
        not_before: { type: string, format: date-time }
        not_after: { type: string, format: date-time }
        days_until_expiry:
          type: integer
          description: |
            Negative once expired. When this reaches zero the company's entire fleet stops being
            able to fiscalize, so notices go out at 60, 30, 14, 7 and 1 days.
        status:
          $ref: "#/components/schemas/CertificateStatus"
        uploaded_at: { type: string, format: date-time }

    CertificateList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items:
            $ref: "#/components/schemas/Certificate"
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    Country:
      type: object
      required: [code, capabilities, fiscalization_deadline_days]
      properties:
        code:
          $ref: "#/components/schemas/CountryCode"
        capabilities:
          type: array
          items:
            type: string
            enum: [unattended_sale, ordinary_receipt, echo, invoice_check, working_hours]
        fiscalization_deadline_days:
          type: integer
          description: |
            How many days after the sale it stops being fiscalizable. 30 in Croatia — the cliff
            behind `hr.sale_older_than_30_days`.
    HealthCheck:
      type: object
      required: [ok, latency_ms]
      properties:
        ok: { type: boolean }
        latency_ms:
          type: integer
          description: |
            How long the check took, including a failed one — the elapsed time up to the
            failure is what says "slow" rather than "refused".

    Health:
      type: object
      required: [status, mode, environment, version, commit, built_at, checks]
      properties:
        status:
          type: string
          enum: [ok, degraded]
          description: |
            `degraded` accompanies HTTP 503: a dependency the request path needs is down.
        mode:
          $ref: "#/components/schemas/FiscalMode"
          description: |
            Which fiscal world this process is wired to. The most useful field here when
            several stacks look identical.
        environment:
          type: string
          enum: [development, test, production]
          description: |
            `NODE_ENV`. Read it together with `mode`: a stack whose environment was never
            configured reports `development` / `test` while looking entirely healthy, and is
            filing to the test endpoint with a development CORS allowlist.
          example: production
        version:
          type: string
          description: The release this build came from.
          example: 0.1.0
        commit:
          type: string
          description: |
            The full commit SHA this image was built from, or `local` when the image was not
            built by CI — which on a deployed stack is itself the finding.
          example: 4f2a9c1e8b7d3a5f6c0e1b2d3a4f5c6e7b8d9a0f
        built_at:
          type: string
          description: |
            When the image was built (ISO 8601), or `unknown`. Not `format: date-time`, because
            `unknown` is a legitimate value and a strict validator would reject it.
          example: "2026-09-08T13:04:11Z"
        checks:
          type: object
          required: [database]
          properties:
            database:
              $ref: "#/components/schemas/HealthCheck"

    FiscalHealth:
      type: object
      required: [reachable, latency_ms, endpoint, mode, checked_at, detail]
      description: |
        The result of one echo probe. Read `reachable`, not the HTTP status — the HTTP status
        reports whether the probe ran, which it always does.
      properties:
        reachable:
          type: boolean
          description: |
            Whether a matching `EchoResponse` came back. **The answer.** A scheduled TEST
            maintenance window looks exactly like any other outage here, because on the wire it
            is one.
        latency_ms:
          type: [integer, "null"]
          description: |
            Round trip in milliseconds, or the time elapsed up to the failure when unreachable.
        endpoint:
          type: string
          format: uri
          description: The Tax Administration endpoint that was probed.
        mode:
          $ref: "#/components/schemas/FiscalMode"
          description: |
            Which endpoint this deployment is configured against. Decided by configuration, not
            by the request — there is no way to ask for the other one.
        checked_at:
          type: string
          format: date-time
          description: |
            When the probe ran. Results are cached for about 15 seconds, so this may be slightly
            in the past.
        detail:
          type: [string, "null"]
          description: Why it failed, or `null`. Human-readable; not part of the contract.

    ApiErrorBody:
      type: object
      required: [error]
      description: |
        Every `4xx` and `5xx` in this API has this shape. `code` is the stable machine-readable
        identifier — branch on it, never on `message`, which is written for humans and may be
        reworded.
      properties:
        error:
          type: object
          required: [type, code, message, param, upstream, doc_url]
          properties:
            type:
              type: string
              enum:
                - validation_error
                - authentication_error
                - authorization_error
                - not_found
                - conflict
                - rate_limit_error
                - fiscal_error
                - upstream_error
                - internal_error
              description: The broad category, useful for generic handling when a specific `code` is unknown to you.
            code:
              type: string
              description: |
                The stable code. Never renamed, never repurposed. New codes may be added, so
                treat an unrecognised one as its `type`.
              examples: [hr.device_missing_isu]
            message:
              type: string
              description: Human-readable and actionable — it says what to do, not only what broke.
            param:
              type: [string, "null"]
              description: The offending request field or header, when there is one.
            upstream:
              type: [object, "null"]
              description: |
                Raw detail from the Tax Administration, for debugging only. **Never part of the
                contract** — do not branch on it.
              properties:
                code: { type: string, examples: ["s002"] }
                message: { type: string }
            doc_url:
              type: string
              format: uri
              description: The documentation page for this exact code. Always resolves.
              examples: [https://docs.fiskhub.com/errors/hr.device_missing_isu]
