openapi: 3.1.0
info:
  title: CyfroPass Public API
  version: "1.0.0"
  description: |
    Public REST API (v1) for the CyfroPass Digital Product Passport platform.

    ## Availability
    The API is available on **every plan** (Free included). Tiers differ by the
    daily quota, not by a boolean. Every request still re-checks the tenant's
    live entitlement, so a fully suspended account answers `403 api_not_in_plan`.

    ## Authentication
    Authenticate every request with an API key, either as a bearer token or in
    the `X-API-Key` header:

    ```
    Authorization: Bearer cp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    X-API-Key: cp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    ```

    New keys carry the `cp_live_` prefix (CyfroPass). Keys issued before the
    rebrand (`pf_live_`) keep working — validation is by hash, not by prefix.
    Keys are created by a workspace **owner** in the app under
    Settings → API keys (or `POST /api/tenant/apikeys`). The raw key is shown
    **once** at creation and stored only as a salted hash — keep it secret.
    A key authenticates as its tenant with **editor** rights: it can read and
    write product data, publish passports and run imports, but never touches
    billing, users or tenant settings (those endpoints are not part of v1).

    ## Rate limits
    Enforcement is a **per-UTC-day quota** scoped to the workspace, split into
    **writes/day** (POST/PATCH/PUT/DELETE) and **passport reads/day** (GET on
    the passport endpoints). Limits come from the plan (Free 100 writes + 100
    reads/day; Starter 1,000 writes/day + unlimited reads; Business & Scale
    unlimited). `0` means unlimited. On the ceiling the API returns **429** with
    a flat body, e.g.:

    ```
    {"error":"API rate limit: 100 writes/day via /api/v1. Currently 100 today; requested 1. Retry tomorrow (UTC) or upgrade your plan."}
    ```

    The window is non-rolling and resets at 00:00 UTC. A small per-minute burst
    guard also applies (`429 rate_limited`).

    ## Idempotency
    Send an `Idempotency-Key` header (a UUID or opaque string ≤ 255 chars) on any
    write (POST/PATCH/DELETE). The first response is stored and **replayed** for
    24h when the same key is retried with the same body. Reusing the key with a
    **different** body returns `422`. Keys over 255 chars return `400`.

    ## Errors
    Every error uses a uniform envelope with a stable, machine-readable `code`
    (see the `Error` schema). Some endpoints add a `fields` or `details` array
    with per-field validation reasons.

    ## Pagination
    List endpoints that paginate accept `limit` (1–200, default 50) and
    `offset` (>= 0, default 0), and return `total`, `limit` and `offset`
    alongside the items.
  contact:
    name: CyfroPass
    url: https://passflow.example
servers:
  - url: https://{host}/v1
    description: Origin host; replace {host} with your CyfroPass domain.
    variables:
      host:
        default: app.passflow.example
security:
  - bearerAuth: []
  - apiKeyHeader: []
tags:
  - name: Products
    description: Product catalogue (SKUs) and attached documents.
  - name: Passports
    description: Passport lifecycle — publish, re-publish, archive, version history.
  - name: Templates
    description: Read-only product-group templates and validation.
  - name: Import
    description: CSV / e-commerce store import wizard.
  - name: Batches & Serials
    description: >-
      Model → batch → serial data-inheritance hierarchy and per-level passport
      publishing (reachable via the v1 API-key surface, same as Products).
  - name: Compliance
    description: Per-product "what's missing" report and delegated-act template-update advisories.
  - name: Suppliers
    description: >-
      Supplier data-request forms (Feature B). NOT part of the v1 API-key
      surface — session-authenticated origin-root endpoints (tenant routes) and
      token-gated public endpoints. Documented here for completeness.
  - name: Billing
    description: >-
      Company / tax invoice details. Session + owner role, origin root — NOT
      part of the v1 API-key surface.
  - name: Auth
    description: >-
      Account self-service. Session-authenticated, origin root — NOT part of the
      v1 API-key surface.

paths:
  /products:
    get:
      tags: [Products]
      summary: List products
      description: Lists products in the authenticated tenant. Paginated.
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [draft, published, archived] } }
        - { name: template_id, in: query, required: false, schema: { type: string } }
        - { name: q, in: query, required: false, description: Substring match on name or GTIN., schema: { type: string } }
        - { name: sort, in: query, required: false, schema: { type: string, enum: [name, created_at, updated_at, completeness, status], default: created_at } }
        - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema:
                type: object
                properties:
                  products: { type: array, items: { $ref: "#/components/schemas/Product" } }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NotInPlan" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Products]
      summary: Create a product
      description: |
        Creates a product from a template version. Fails with
        `sku_limit_reached` (402) once the plan's SKU ceiling is reached.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreate" }
      responses:
        "201":
          description: The created product plus this request's validation result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductWithValidation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: SKU limit reached for the plan.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403": { $ref: "#/components/responses/NotInPlan" }
        "422":
          description: Unknown template version (`template_not_found`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /products/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Products]
      summary: Get a product
      responses:
        "200":
          description: The product.
          content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Products]
      summary: Update a product
      description: |
        Partially updates mutable fields. `data` is **merged** at the top level
        (per-key overwrite; omit a key to leave it untouched). `status` is
        read-only here — it changes only through publish/archive.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpdate" }
      responses:
        "200":
          description: The updated product plus its validation result.
          content: { application/json: { schema: { $ref: "#/components/schemas/ProductWithValidation" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Products]
      summary: Delete a draft product
      description: Only draft products can be deleted; published/archived → 409 not_draft.
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Product is not a draft (`not_draft`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /products/{id}/documents:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Products]
      summary: List a product's documents
      responses:
        "200":
          description: Documents attached to the product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documents: { type: array, items: { $ref: "#/components/schemas/Document" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Products]
      summary: Upload a document
      description: Multipart upload. Max 25 MiB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
                kind: { type: string, description: "Free-text label (e.g. certificate, image, manual). Defaults to `document`." }
              required: [file]
      responses:
        "201":
          description: The stored document metadata.
          content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: File too large (> 25 MiB) (`file_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /products/{id}/documents/{docID}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: docID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Products]
      summary: Download a document
      description: Streams the document bytes (Content-Disposition attachment).
      responses:
        "200":
          description: The document bytes.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Products]
      summary: Delete a document
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/template-updates:
    get:
      tags: [Compliance]
      summary: Tenant template-update summary
      description: >-
        Outdated products grouped by template (spec §2.3): each group carries the
        latest version, migration hint, deadline and the affected products.
        Products already on the latest version are omitted. Advisory only.
        Mounted BEFORE `/{id}`. `501 not_implemented` when the detector is unwired.
      responses:
        "200":
          description: Outdated products grouped by template.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updates: { type: array, items: { $ref: "#/components/schemas/TemplateUpdateGroup" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /products/{id}/compliance:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Compliance]
      summary: Product "what's missing" report
      description: >-
        The product's working data validated against its template version: the
        required-field counts and the required-but-unfilled fields (a field
        hidden by `visible_when` never counts as missing). `501 not_implemented`
        when no compliance reporter is wired.
      responses:
        "200":
          description: The compliance report.
          content: { application/json: { schema: { $ref: "#/components/schemas/ComplianceReport" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /products/{id}/template-update:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Compliance]
      summary: Per-product template-update notice
      description: >-
        Whether a newer version of the product's template exists and, if so, the
        migration hint, latest version and compliance deadline. Advisory only —
        the stored `template_version` is never changed. `501 not_implemented`
        when the detector is unwired.
      responses:
        "200":
          description: The template-update notice.
          content: { application/json: { schema: { $ref: "#/components/schemas/TemplateUpdate" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /products/{id}/batches:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Batches & Serials]
      summary: List a product's batches
      responses:
        "200":
          description: The product's batches (with effective-data completeness).
          content:
            application/json:
              schema:
                type: object
                properties:
                  batches: { type: array, items: { $ref: "#/components/schemas/BatchListItem" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Batches & Serials]
      summary: Create a batch
      description: >-
        A batch stores a sparse override map (only the keys it overrides);
        absent keys inherit the product's data. The override is pruned to the
        template's declared field keys.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchCreate" }
      responses:
        "201":
          description: The created batch (detail DTO).
          content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/batches/{batchId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Batches & Serials]
      summary: Get a batch
      responses:
        "200":
          description: The batch detail DTO.
          content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Batches & Serials]
      summary: Update a batch
      description: >-
        `batch_code` and `data` are both optional. When `data` is present it
        **replaces** the sparse override map wholesale (pruned to known fields),
        not merged; when absent it is left unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchUpdate" }
      responses:
        "200":
          description: The updated batch detail DTO.
          content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Batches & Serials]
      summary: Delete a batch
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/batches/{batchId}/publish:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Batches & Serials]
      summary: Publish or re-publish a batch passport
      description: >-
        Freezes the batch's effective (product ⊕ batch) data into its own
        passport snapshot, mints/reuses a uid + GS1/QR carrier. A batch passport
        is an instance of the model and does NOT consume the SKU quota.
      responses:
        "201":
          description: The published batch passport state.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Account is read-only (`read_only`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    delete:
      tags: [Batches & Serials]
      summary: Unpublish a batch passport
      description: Archives the batch passport (410 Gone publicly after the §4.3 grace window).
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                type: object
                properties: { state: { type: string, example: archived } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /products/{id}/batches/{batchId}/serials:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Batches & Serials]
      summary: List a batch's serials
      responses:
        "200":
          description: The batch's serials.
          content:
            application/json:
              schema:
                type: object
                properties:
                  serials: { type: array, items: { $ref: "#/components/schemas/SerialListItem" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Batches & Serials]
      summary: Create a serial
      description: >-
        A serial belongs to one batch and stores its own sparse override map;
        absent keys inherit the batch's effective data (product ⊕ batch ⊕ serial).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SerialCreate" }
      responses:
        "201":
          description: The created serial (detail DTO).
          content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/batches/{batchId}/serials/{serialId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: serialId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Batches & Serials]
      summary: Get a serial
      responses:
        "200":
          description: The serial detail DTO.
          content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Batches & Serials]
      summary: Update a serial
      description: >-
        `serial_number` and `data` are optional. When `data` is present it
        replaces the serial's sparse override map wholesale (pruned to known
        fields); when absent it is left unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SerialUpdate" }
      responses:
        "200":
          description: The updated serial detail DTO.
          content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Batches & Serials]
      summary: Delete a serial
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/serials/{serialId}/publish:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: serialId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Batches & Serials]
      summary: Publish or re-publish a serial passport
      description: >-
        The serial is addressed by id alone (its batch is resolved server-side).
        Freezes the serial's effective (product ⊕ batch ⊕ serial) data into its
        own passport. Does NOT consume the SKU quota.
      responses:
        "201":
          description: The published serial passport state.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Account is read-only (`read_only`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    delete:
      tags: [Batches & Serials]
      summary: Unpublish a serial passport
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                type: object
                properties: { state: { type: string, example: archived } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /passports/products/{productID}:
    parameters:
      - { name: productID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Passports]
      summary: Get a product's current passport
      responses:
        "200":
          description: The current passport with its immutable snapshot.
          content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /passports/products/{productID}/publish:
    parameters:
      - { name: productID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Passports]
      summary: Publish or re-publish a passport
      description: |
        Freezes the product's working data into an immutable snapshot. First
        publish is version 1; re-publishing increments the version while keeping
        the same `uid` and `public_slug`. Enforces required-field validation and
        the plan's published-SKU limit.
      responses:
        "201":
          description: The published passport (with snapshot).
          content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Account is read-only (`read_only`) or SKU limit reached (`sku_limit`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Product has invalid or missing required fields (`validation_failed`).
          content: { application/json: { schema: { $ref: "#/components/schemas/ValidationError" } } }

  /passports/products/{productID}/archive:
    parameters:
      - { name: productID, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Passports]
      summary: Archive the current passport
      responses:
        "200":
          description: The archived passport (no snapshot).
          content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Already archived (`already_archived`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /passports/products/{productID}/versions:
    parameters:
      - { name: productID, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Passports]
      summary: List passport version history
      description: Version metadata, newest first; snapshots omitted.
      responses:
        "200":
          description: Version history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions: { type: array, items: { $ref: "#/components/schemas/Passport" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /passports/products/{productID}/versions/{version}:
    parameters:
      - { name: productID, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: version, in: path, required: true, schema: { type: integer, minimum: 1 } }
    get:
      tags: [Passports]
      summary: Get one passport version
      responses:
        "200":
          description: That version's passport with its immutable snapshot.
          content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates:
    get:
      tags: [Templates]
      summary: List templates
      description: Every product-group template with its versions (compact summaries).
      responses:
        "200":
          description: Templates and versions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        versions: { type: array, items: { $ref: "#/components/schemas/TemplateVersionSummary" } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /templates/field-types:
    get:
      tags: [Templates]
      summary: List generic field types
      responses:
        "200":
          description: The field-type registry.
          content:
            application/json:
              schema:
                type: object
                properties:
                  field_types:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string }
                        widget: { type: string }
                        json_kind: { type: string }
                        label_key: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /templates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Templates]
      summary: List versions of one template
      responses:
        "200":
          description: The template's versions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  versions: { type: array, items: { $ref: "#/components/schemas/TemplateVersionSummary" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown template (`template_not_found`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /templates/{id}/{version}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: version, in: path, required: true, schema: { type: string } }
    get:
      tags: [Templates]
      summary: Get the full template document
      description: The complete template JSON (sections and fields) for a version.
      responses:
        "200":
          description: The full template document.
          content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown version (`version_not_found`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /templates/{id}/{version}/validate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: version, in: path, required: true, schema: { type: string } }
    post:
      tags: [Templates]
      summary: Validate product data against a template version
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { type: object, additionalProperties: true }
              required: [data]
      responses:
        "200":
          description: A validation result.
          content: { application/json: { schema: { $ref: "#/components/schemas/ValidationResult" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown version (`version_not_found`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /templates/{id}/diff:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: from, in: query, required: true, schema: { type: string } }
      - { name: to, in: query, required: true, schema: { type: string } }
    get:
      tags: [Templates]
      summary: Diff two template versions
      description: Machine-readable migration hints (added / removed / changed fields).
      responses:
        "200":
          description: The diff.
          content: { application/json: { schema: { type: object, additionalProperties: true } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown version (`version_not_found`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/upload:
    post:
      tags: [Import]
      summary: Upload a CSV to start an import job
      description: Multipart CSV upload (max 10 MB). Delimiter and encoding are auto-detected.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
              required: [file]
      responses:
        "201":
          description: The created import job with a header/sample preview.
          content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobPreview" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413":
          description: File too large (> 10 MB) (`file_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/sources/test:
    post:
      tags: [Import]
      summary: Test a store connector
      description: Verify connectivity/credentials to a Shopify/WooCommerce/PrestaShop store without creating a job.
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/StoreSource" } }
      responses:
        "200":
          description: Connection ok.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  store_name: { type: string }
                  product_count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502":
          description: Upstream store could not be reached (`connect_failed`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/sources/fetch:
    post:
      tags: [Import]
      summary: Fetch a store catalogue into an import job
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/StoreSource" }
                - type: object
                  properties:
                    limit: { type: integer, minimum: 1, maximum: 5000, default: 1000 }
      responses:
        "201":
          description: The created import job with a header/sample preview.
          content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobPreview" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502":
          description: Upstream store could not be reached (`connect_failed`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/{job}/mapping-suggest:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Import]
      summary: Suggest a column-to-field mapping
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                template_id: { type: string }
                version: { type: string }
              required: [template_id, version]
      responses:
        "200":
          description: Suggested mapping.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string, format: uuid }
                  template_id: { type: string }
                  version: { type: string }
                  mapping:
                    type: array
                    items:
                      type: object
                      properties:
                        header: { type: string }
                        field_key: { type: string }
                        confidence: { type: number }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /import/{job}/validate:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Import]
      summary: Validate mapped rows (no products created)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mapping: { type: object, additionalProperties: { type: string } }
              required: [mapping]
      responses:
        "200":
          description: Validation stats and a per-row error report.
          content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Mapping-suggest was never called (`template_not_selected`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/{job}/execute:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Import]
      summary: Create products from the validated rows
      description: Idempotent — a job already executing/executed returns 409 already_executed.
      responses:
        "200":
          description: Final job stats.
          content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Not validated (`not_validated`) or already executed (`already_executed`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422":
          description: Creating the rows would exceed the plan SKU limit (`sku_limit_exceeded`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /import/{job}:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Import]
      summary: Get import job status
      responses:
        "200":
          description: The job's current status.
          content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Session-authenticated origin-root endpoints. These are NOT part of the v1
  # API-key surface: they live at the origin root (not under /v1) and use the
  # session cookie. The per-path `servers` override reflects that; security is
  # the session cookie, not the API key. Documented here for completeness.
  # ---------------------------------------------------------------------------

  /api/products/{id}/supplier-requests:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Suppliers]
      summary: List a product's supplier requests
      security: [{ cookieAuth: [] }]
      responses:
        "200":
          description: The product's supplier requests (all statuses, newest first).
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/SupplierRequest" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Suppliers]
      summary: Create a supplier request (owner|editor)
      description: >-
        Mints an unguessable token and queues a supplier e-mail. Every requested
        key must be a real field of the product's template.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                field_keys: { type: array, items: { type: string } }
                supplier_email: { type: string, format: email }
                message: { type: string }
              required: [field_keys, supplier_email]
      responses:
        "201":
          description: The created request (with token + form link).
          content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/products/{id}/supplier-requests/{rid}:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: rid, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Suppliers]
      summary: Cancel a supplier request (owner|editor)
      description: Cancels the request; the token stops working.
      security: [{ cookieAuth: [] }]
      responses:
        "204": { description: Cancelled. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/products/{id}/supplier-requests/{rid}/resend:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: rid, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Suppliers]
      summary: Resend the supplier e-mail (owner|editor)
      security: [{ cookieAuth: [] }]
      responses:
        "200":
          description: The request (e-mail re-queued).
          content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The request was cancelled (`request_cancelled`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/products/{id}/supplier-requests/{rid}/apply:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      - { name: rid, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Suppliers]
      summary: Apply staged answers to the product (owner|editor)
      description: >-
        Apply all staged answers, or the given subset (per-field approve). Only
        actually-staged keys are applied; applied keys leave staging. The request
        becomes `applied` once nothing remains.
      security: [{ cookieAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                field_keys: { type: array, items: { type: string } }
      responses:
        "200":
          description: The request after applying.
          content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Request not answered (`not_answered`) or nothing staged (`no_answers`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/supplier/{token}:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: token, in: path, required: true, schema: { type: string }, description: The unguessable capability token (no session). }
    get:
      tags: [Suppliers]
      summary: Fetch the token-gated supplier form (public)
      description: >-
        No session — the token is the capability. Returns ONLY the requested
        fields of one product, each with its current value. An unknown/cancelled
        token → 404.
      security: []
      responses:
        "200":
          description: The public form.
          content: { application/json: { schema: { $ref: "#/components/schemas/SupplierPublicForm" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Suppliers]
      summary: Submit answers to the form (public)
      description: >-
        Values are restricted to the requested fields and format-validated. The
        tenant's submission mode then applies (auto = apply now; review/approve =
        stage). A per-field validation failure returns 400 with a `fields` map.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                values: { type: object, additionalProperties: true }
              required: [values]
      responses:
        "200":
          description: Submission accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [answered, applied] }
                  applied: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Already completed (`already_applied`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/supplier/{token}/documents:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      tags: [Suppliers]
      summary: Upload a document to the form (public)
      description: Multipart upload (field `file`). Max 15 MiB; PDF or image only.
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
              required: [file]
      responses:
        "201":
          description: The stored document reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  document_id: { type: string }
                  filename: { type: string }
                  size: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Already completed (`already_applied`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "413":
          description: File too large (> 15 MiB) (`file_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "415":
          description: Unsupported content type (`unsupported_type`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Document storage unavailable (`storage_unavailable`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/billing/details:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    get:
      tags: [Billing]
      summary: Get company / tax invoice details (owner)
      security: [{ cookieAuth: [] }]
      responses:
        "200":
          description: The tenant's billing details plus derived flags.
          content: { application/json: { schema: { $ref: "#/components/schemas/BillingDetailsResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Not the owner role.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    put:
      tags: [Billing]
      summary: Save company / tax invoice details (owner)
      description: >-
        Validates + persists the details, then best-effort syncs them to the
        Stripe customer (`eu_vat` tax id). A bare national NIP is normalized to
        its EU-VAT form (e.g. PL + 1234567890 → PL1234567890). The DB is the
        source of truth: a Stripe hiccup returns 200 with `stripe_synced:false`.
        These details gate checkout / AI-credit top-up (`billing_details_required`).
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BillingDetails" }
      responses:
        "200":
          description: The normalized details plus flags.
          content: { application/json: { schema: { $ref: "#/components/schemas/BillingDetailsResponse" } } }
        "400":
          description: Invalid/missing field (`billing_details_invalid`) or malformed body (`invalid_body`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Not the owner role.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/auth/password:
    servers:
      - url: https://{host}
        variables: { host: { default: app.passflow.example } }
    post:
      tags: [Auth]
      summary: Change your password
      description: >-
        Changes the signed-in user's own password. The current password
        re-authenticates the request; the new one must meet the minimum length.
        SSO accounts have no local password. The password value is never logged
        (audit records only `user.password_changed`). Rate-limited (10/min/IP).
      security: [{ cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password }
              required: [current_password, new_password]
      responses:
        "200":
          description: Password changed.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, example: true } }
        "400":
          description: New password too short (`password_too_short`) or malformed body (`invalid_body`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: Current password incorrect (`invalid_credentials`), or no session.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: SSO-managed account (`sso_managed`) — change the password at the IdP.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key as a bearer token: `Authorization: Bearer cp_live_...` (legacy `pf_live_` keys still accepted)."
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: "API key in the `X-API-Key` header."
    cookieAuth:
      type: apiKey
      in: cookie
      name: dpp_session
      description: >-
        Session cookie set by the browser login flow. Used by the
        origin-root `/api/*` endpoints (NOT the v1 API-key surface).
  responses:
    Unauthorized:
      description: Missing or invalid API key (`invalid_api_key`).
      headers:
        WWW-Authenticate: { schema: { type: string } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotInPlan:
      description: The tenant's plan does not include API access (`api_not_in_plan`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Per-key rate limit exceeded (`rate_limited`).
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait before retrying. }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found in the caller's tenant (`not_found`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BadRequest:
      description: Malformed request (`invalid_body`, `invalid_format`, `invalid_input`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotImplemented:
      description: The delegated feature is not configured on this deployment (`not_implemented`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    Error:
      type: object
      description: Uniform error envelope. `code` is a stable snake_case identifier.
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: not_found }
            message: { type: string }
          required: [code, message]
      required: [error]
    ValidationError:
      type: object
      description: Error envelope with per-field publish-validation reasons.
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: validation_failed }
            message: { type: string }
            fields:
              type: array
              items:
                type: object
                properties:
                  field: { type: string }
                  code: { type: string }
                  message: { type: string }
          required: [code, message]
      required: [error]
    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        template_id: { type: string }
        template_version: { type: string }
        name: { type: string }
        gtin: { type: [string, "null"] }
        status: { type: string, enum: [draft, published, archived] }
        completeness: { type: integer, minimum: 0, maximum: 100 }
        has_unpublished_changes: { type: boolean }
        data: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProductCreate:
      type: object
      properties:
        template_id: { type: string }
        template_version: { type: string }
        name: { type: string }
        gtin: { type: string }
        data: { type: object, additionalProperties: true }
      required: [template_id, template_version, name]
    ProductUpdate:
      type: object
      properties:
        name: { type: string }
        gtin: { type: string }
        data: { type: object, additionalProperties: true }
    ProductWithValidation:
      allOf:
        - { $ref: "#/components/schemas/Product" }
        - type: object
          properties:
            validation: { $ref: "#/components/schemas/ValidationResult" }
    Document:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        kind: { type: string }
        sha256: { type: string }
        size: { type: integer }
        created_at: { type: string, format: date-time }
    Passport:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        uid: { type: string }
        version: { type: integer }
        state: { type: string, enum: [published, updated, archived] }
        public_slug: { type: string }
        template_version: { type: string }
        is_current: { type: boolean }
        published_at: { type: string, format: date-time }
        archived_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        snapshot:
          type: object
          description: Immutable frozen state (present on single reads and publish).
          additionalProperties: true
    ValidationResult:
      type: object
      properties:
        template_id: { type: string }
        version: { type: string }
        valid: { type: boolean }
        completeness: { type: number, description: "0..1; validated required / all required." }
        required_total: { type: integer }
        required_valid: { type: integer }
        errors:
          type: array
          items:
            type: object
            properties:
              section: { type: string }
              field: { type: string }
              type: { type: string }
              code: { type: string }
    TemplateVersionSummary:
      type: object
      properties:
        id: { type: string }
        version: { type: string }
        status: { type: string }
        granularity: { type: string }
        compliance_deadline: { type: [string, "null"] }
        section_count: { type: integer }
        field_count: { type: integer }
        required_count: { type: integer }
    Template:
      type: object
      properties:
        id: { type: string }
        version: { type: string }
        status: { type: string }
        compliance_deadline: { type: [string, "null"] }
        granularity: { type: string }
        sections:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              label_key: { type: string }
              fields:
                type: array
                items:
                  type: object
                  additionalProperties: true
    StoreSource:
      type: object
      properties:
        platform: { type: string, enum: [shopify, woocommerce, prestashop] }
        store_url: { type: string }
        credentials:
          type: object
          additionalProperties: { type: string }
      required: [platform, store_url, credentials]
    ImportJobPreview:
      type: object
      properties:
        job_id: { type: string, format: uuid }
        headers: { type: array, items: { type: string } }
        sample_rows: { type: array, items: { type: array, items: { type: string } } }
        row_count: { type: integer }
        delimiter: { type: string }
        encoding: { type: string }
    ImportJobStatus:
      type: object
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, enum: [uploaded, validated, executing, executed, failed] }
        filename: { type: string }
        headers: { type: array, items: { type: string } }
        row_count: { type: integer }
        template_id: { type: string }
        template_version: { type: string }
        mapping: { type: object, additionalProperties: { type: string } }
        stats: { type: object, additionalProperties: { type: integer } }
        report:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              field: { type: string }
              message: { type: string }
        error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    BatchListItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        batch_code: { type: string }
        completeness: { type: integer, minimum: 0, maximum: 100 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    BatchCreate:
      type: object
      properties:
        batch_code: { type: string }
        data: { type: object, additionalProperties: true, description: Sparse override map (pruned to template fields). }
      required: [batch_code]
    BatchUpdate:
      type: object
      description: "`data`, when present, REPLACES the sparse override map wholesale."
      properties:
        batch_code: { type: string }
        data: { type: object, additionalProperties: true }
    BatchDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        batch_code: { type: string }
        data: { type: object, additionalProperties: true, description: The batch's own sparse override map. }
        effective_data: { type: object, additionalProperties: true, description: product.data overlaid with the batch override. }
        inherited_data: { type: object, additionalProperties: true, description: The parent product's effective data. }
        completeness: { type: integer, minimum: 0, maximum: 100 }
        published: { $ref: "#/components/schemas/PublishedState" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SerialListItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        batch_id: { type: string, format: uuid }
        serial_number: { type: string }
        completeness: { type: integer, minimum: 0, maximum: 100 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SerialCreate:
      type: object
      properties:
        serial_number: { type: string }
        data: { type: object, additionalProperties: true }
      required: [serial_number]
    SerialUpdate:
      type: object
      properties:
        serial_number: { type: string }
        data: { type: object, additionalProperties: true }
    SerialDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        batch_id: { type: string, format: uuid }
        serial_number: { type: string }
        data: { type: object, additionalProperties: true, description: The serial's own sparse override map. }
        effective_data: { type: object, additionalProperties: true, description: Batch effective data overlaid with the serial override. }
        inherited_data: { type: object, additionalProperties: true, description: The batch's effective data. }
        completeness: { type: integer, minimum: 0, maximum: 100 }
        published: { $ref: "#/components/schemas/PublishedState" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    PublishResult:
      type: object
      description: The published state returned by a batch/serial publish.
      properties:
        uid: { type: string }
        version: { type: integer }
        state: { type: string, enum: [published, updated, archived] }
        public_url: { type: string }
        gs1_link: { type: string }
        qr_svg_url: { type: string }
        qr_pdf_url: { type: string }
        passport_id: { type: string }
    PublishedState:
      type: object
      description: Published-passport state on a batch/serial detail DTO (omitted until first publish).
      properties:
        uid: { type: string }
        version: { type: integer }
        state: { type: string, enum: [published, updated, archived] }
        public_url: { type: string }
        gs1_link: { type: string }
        qr_svg_url: { type: string }
        qr_pdf_url: { type: string }
    ComplianceReport:
      type: object
      properties:
        completeness: { type: integer, minimum: 0, maximum: 100 }
        required_total: { type: integer }
        required_filled: { type: integer }
        missing:
          type: array
          items:
            type: object
            properties:
              section: { type: string }
              field_key: { type: string }
              label: { type: string }
              type: { type: string }
              access: { type: string, enum: [public, restricted, professional] }
    TemplateUpdate:
      type: object
      properties:
        outdated: { type: boolean }
        current_version: { type: string }
        latest_version: { type: string }
        latest_status: { type: string }
        hint: { type: string }
        added_required: { type: integer }
        deadline: { type: [string, "null"] }
    TemplateUpdateGroup:
      type: object
      properties:
        template_id: { type: string }
        latest_version: { type: string }
        latest_status: { type: string }
        hint: { type: string }
        added_required: { type: integer }
        deadline: { type: [string, "null"] }
        affected_count: { type: integer }
        products:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              current_version: { type: string }
    SupplierRequest:
      type: object
      description: Tenant-facing supplier request (token + form link exposed to the tenant only).
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        supplier_email: { type: string, format: email }
        message: { type: string }
        fields: { type: array, items: { type: string } }
        status: { type: string, enum: [open, answered, applied, cancelled] }
        answers: { type: object, additionalProperties: true, description: Staged answers (only while status is answered). }
        token: { type: string }
        form_link: { type: string }
        answered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    SupplierPublicForm:
      type: object
      description: The token-gated public form — only the requested fields of one product.
      properties:
        product_name: { type: string }
        tenant_name: { type: string }
        status: { type: string, enum: [open, answered, applied, cancelled] }
        message: { type: string }
        fields:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              type: { type: string }
              access: { type: string, enum: [public, restricted, professional] }
              required: { type: boolean }
              config: { type: [object, "null"], additionalProperties: true }
              current_value: {}
    BillingDetails:
      type: object
      description: Company / tax invoice details. `tax_id` is normalized to EU-VAT form.
      properties:
        company_name: { type: string }
        tax_id: { type: string, description: "EU-VAT or a bare national NIP (prefixed with country → eu_vat)." }
        line1: { type: string }
        line2: { type: string }
        city: { type: string }
        postal_code: { type: string }
        country: { type: string, description: 2-letter ISO country code. }
      required: [company_name, tax_id, line1, city, postal_code, country]
    BillingDetailsResponse:
      allOf:
        - { $ref: "#/components/schemas/BillingDetails" }
        - type: object
          properties:
            complete: { type: boolean, description: Every mandatory field present (the invoicing gate). }
            stripe_synced: { type: boolean, description: Whether the best-effort Stripe sync succeeded (PUT only). }
