# Draft v2 kontraktu publicznego API NavyFlame (Faza 0, #642 / EPIC #640).
# Zrodlo prawdy dla implementacji w IE (Fazy 2-4). Opis decyzji: public-api-contract.md.
# Konwencje nullowalnosci (OpenAPI 3.1): primitives -> type: [X, 'null']; $ref -> oneOf z { type: 'null' };
# nazwane schematy nigdy nie sa nullowalne same w sobie; nullable enum zawiera literal null (JSON Schema).
# Kazda operacja moze dodatkowo zwrocic 500 (internal) i 503 (service_unavailable) - nie deklarowane per-op.
openapi: 3.1.0
info:
  title: NavyFlame Public API
  version: 1.0.0-draft2
  description: |
    Publiczne API sprzedawcy NavyFlame. Server-to-server, auth kluczem API (scopes per klucz;
    X:write implikuje X:read), limity per TENANT (agregat kluczy) + per-IP pre-auth, paginacja
    keyset-cursor, bledy RFC 9457 (kazdy Problem niesie requestId), zapisy z Idempotency-Key.
    Kazda odpowiedz niesie X-Request-Id, X-RateLimit-*, Cache-Control: no-store, nosniff.
    Klient MUSI ignorowac nieznane pola i traktowac krotkie 502/503 jako retryable (deploye).
  contact:
    name: NavyFlame
    url: https://navyflame.com
servers:
  - url: https://{slug}.navyflame.com/api/public/v1
    variables:
      slug:
        default: moj-sklep
        description: Subdomena tenanta (niezmienna przez caly cykl zycia tenanta)

security:
  - bearerAuth: []
  - apiKeyHeader: []

tags:
  - name: meta
  - name: orders
  - name: warehouse-items
  - name: invoices
  - name: shipments

x-success-headers: &successHeaders
  X-Request-Id: { $ref: '#/components/headers/XRequestId' }
  X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
  X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
  X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }

paths:
  /me:
    get:
      tags: [meta]
      operationId: getMe
      summary: Introspekcja klucza (bez scope - wystarczy wazny klucz)
      description: Pierwszy krok quickstartu i "test connection" dla Zapier/Make.
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /orders:
    get:
      tags: [orders]
      operationId: listOrders
      summary: Lista zamowien (orderDate desc, NULL orderDate -> createdAt; keyset cursor)
      security: [{ bearerAuth: [orders:read] }, { apiKeyHeader: [orders:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
        - { name: status, in: query, schema: { type: string, enum: [New, Processing, Completed, Cancelled] } }
        - { name: paymentStatus, in: query, schema: { type: string, enum: [Unpaid, Paid, Refunded, PartiallyRefunded] } }
        - { name: source, in: query, schema: { type: string, maxLength: 50 }, description: 'np. allegro, shopify, api' }
        - { name: dateFrom, in: query, schema: { type: string, format: date-time }, description: orderDate >= }
        - { name: dateTo, in: query, schema: { type: string, format: date-time }, description: orderDate <= }
        - { name: search, in: query, schema: { type: string, minLength: 2, maxLength: 200 }, description: numer / klient / email (case-insensitive substring) }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Order' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [orders]
      operationId: createOrder
      summary: Utworz zamowienie (source=api) - Faza 4
      description: |
        Kwoty liczy serwer (half-up, 2 miejsca, per pozycja). Automatyzacje domyslnie DZIALAJA
        (opt-out suppressAutomations; fakturowanie mozna pozniej wyzwolic przez POST /orders/{id}/invoice).
        Nie zdejmuje stanu magazynowego w v1.
      security: [{ bearerAuth: [orders:write] }, { apiKeyHeader: [orders:write] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrderRequest' }
      responses:
        '201':
          description: Utworzono
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /orders/{id}:
    get:
      tags: [orders]
      operationId: getOrder
      summary: Szczegoly zamowienia
      security: [{ bearerAuth: [orders:read] }, { apiKeyHeader: [orders:read] }]
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /orders/{id}/status:
    put:
      tags: [orders]
      operationId: updateOrderStatus
      summary: Zmien status zamowienia - Faza 4
      description: |
        Macierz przejsc (wspolny walidator panel+API): New->Processing/Cancelled;
        Processing->Completed/Cancelled; Completed i Cancelled sa terminalne. Niedozwolone -> 422.
        Side-effect: source=allegro wykonuje best-effort writeback fulfillmentu do Allegro
        (blad pusha nie cofa zmiany lokalnej).
      security: [{ bearerAuth: [orders:write] }, { apiKeyHeader: [orders:write] }]
      parameters:
        - $ref: '#/components/parameters/Id'
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [New, Processing, Completed, Cancelled] }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /orders/{id}/invoice:
    post:
      tags: [orders]
      operationId: triggerOrderInvoice
      summary: Wyzwol fakturowanie zamowienia - Faza 4
      description: |
        Idempotentny odpowiednik panelowego trigger-invoice (reuse istniejacej sciezki IE).
        Fakturowanie jest asynchroniczne - wynik w invoices[] zamowienia / webhook invoice.issued.
        Domyka scenariusz POST /orders z suppressAutomations=true.
      security: [{ bearerAuth: [orders:write] }, { apiKeyHeader: [orders:write] }]
      parameters:
        - $ref: '#/components/parameters/Id'
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      responses:
        '202':
          description: Fakturowanie wyzwolone
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /warehouse-items:
    get:
      tags: [warehouse-items]
      operationId: listWarehouseItems
      summary: Lista produktow magazynowych (createdAt desc; keyset cursor)
      security: [{ bearerAuth: [catalog:read] }, { apiKeyHeader: [catalog:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
        - { name: status, in: query, schema: { type: string, enum: [Active, Draft, Archived] } }
        - { name: sku, in: query, schema: { type: string, maxLength: 100 } }
        - { name: barcode, in: query, schema: { type: string, maxLength: 100 } }
        - { name: search, in: query, schema: { type: string, minLength: 2, maxLength: 200 }, description: tytul / sku }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/WarehouseItem' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /warehouse-items/{id}:
    get:
      tags: [warehouse-items]
      operationId: getWarehouseItem
      summary: Szczegoly produktu
      security: [{ bearerAuth: [catalog:read] }, { apiKeyHeader: [catalog:read] }]
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WarehouseItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /warehouse-items/{id}/stock:
    patch:
      tags: [warehouse-items]
      operationId: updateStock
      summary: Ustaw / skoryguj stan magazynowy - Faza 4
      description: |
        set = stan absolutny (last-write-wins); adjust = przesuniecie ATOMOWE na poziomie DB
        (UPDATE ... SET stock = stock + delta z guardem >= 0). Bez variantId operuje na produkcie,
        z variantId na wariancie. Wynik stockQuantity < 0 -> 422 (chyba ze allowBackorder).
        Produkt locationManaged=true lub isBundle=true -> 422 (stan zarzadzany lokalizacjami /
        komponentami zestawu). Propagacja na marketplace asynchroniczna i koalescowana
        (last-value-wins per SKU+connector) - odpowiedz zwraca stan lokalny natychmiast.
      security: [{ bearerAuth: [catalog:write] }, { apiKeyHeader: [catalog:write] }]
      parameters:
        - $ref: '#/components/parameters/Id'
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation, quantity]
              properties:
                operation: { type: string, enum: [set, adjust] }
                quantity: { type: integer }
                variantId: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WarehouseItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices:
    get:
      tags: [invoices]
      operationId: listInvoices
      summary: Lista faktur (issueDate desc; keyset cursor)
      security: [{ bearerAuth: [invoices:read] }, { apiKeyHeader: [invoices:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
        - { name: status, in: query, schema: { type: string, enum: [Draft, Issued, Sent, Paid, Cancelled] } }
        - { name: type, in: query, schema: { type: string, enum: [Invoice, CreditNote, Proforma] } }
        - { name: orderId, in: query, schema: { type: string, format: uuid } }
        - { name: dateFrom, in: query, schema: { type: string, format: date-time }, description: issueDate >= }
        - { name: dateTo, in: query, schema: { type: string, format: date-time }, description: issueDate <= }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Invoice' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices/{id}:
    get:
      tags: [invoices]
      operationId: getInvoice
      summary: Szczegoly faktury
      security: [{ bearerAuth: [invoices:read] }, { apiKeyHeader: [invoices:read] }]
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /shipments:
    get:
      tags: [shipments]
      operationId: listShipments
      summary: Lista przesylek (createdAt desc; keyset cursor)
      security: [{ bearerAuth: [shipments:read] }, { apiKeyHeader: [shipments:read] }]
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
        - { name: status, in: query, schema: { type: string, enum: [Draft, LabelRequested, LabelReady, PickedUp, InTransit, Delivered, Returned, Cancelled] } }
        - { name: orderId, in: query, schema: { type: string, format: uuid } }
        - { name: courierProvider, in: query, schema: { type: string, maxLength: 50 }, description: 'np. inpost, dpd, dhl' }
        - { name: trackingNumber, in: query, schema: { type: string, maxLength: 100 } }
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Shipment' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /shipments/{id}:
    get:
      tags: [shipments]
      operationId: getShipment
      summary: Szczegoly przesylki (z historia trackingu)
      security: [{ bearerAuth: [shipments:read] }, { apiKeyHeader: [shipments:read] }]
      parameters: [{ $ref: '#/components/parameters/Id' }]
      responses:
        '200':
          description: OK
          headers: *successHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Shipment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

# Webhooki wychodzace (OpenAPI 3.1). Envelope + 4 typy zdarzen v1.
# Podpis: X-NavyFlame-Signature: t=<unix>,v1=hex(hmac-sha256(secret, t + "." + rawBody)), tolerancja 300 s.
# Podpis liczony NA NOWO przy kazdej probie (outbox trzyma payload, nie podpis); naglowek MOZE zawierac
# wiele par v1= (okno rotacji sekretu) - odbiorca akceptuje dowolna pasujaca.
# Dostarczanie: retry ~1min/10min/1h/4h/12h/24h -> DLQ (+ notyfikacja); at-least-once, bez kolejnosci;
# dedup po id zdarzenia i X-NavyFlame-Delivery-Id. Pole origin przerywa petle echa (channel + apiKeyPrefix).
webhooks:
  order.synced:
    post:
      summary: Zamowienie zapisane - TYLKO przy realnej zmianie danych (change-detection); import historyczny nie emituje per-order
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    type: { const: order.synced }
                    data:
                      type: object
                      required: [order, isNew]
                      properties:
                        order: { $ref: '#/components/schemas/Order' }
                        isNew: { type: boolean }
      responses:
        '200': { description: Odbiorca potwierdza (dowolny 2xx w <= 5 s) }
  invoice.issued:
    post:
      summary: Faktura wystawiona w systemie ksiegowym
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    type: { const: invoice.issued }
                    data:
                      type: object
                      required: [invoice]
                      properties:
                        invoice: { $ref: '#/components/schemas/Invoice' }
      responses:
        '200': { description: Odbiorca potwierdza (dowolny 2xx w <= 5 s) }
  shipment.status_updated:
    post:
      summary: Zmiana statusu przesylki
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    type: { const: shipment.status_updated }
                    data:
                      type: object
                      required: [shipment]
                      properties:
                        shipment: { $ref: '#/components/schemas/Shipment' }
                        previousStatus: { type: ['string', 'null'] }
      responses:
        '200': { description: Odbiorca potwierdza (dowolny 2xx w <= 5 s) }
  catalog.updated:
    post:
      summary: Zmiana produktu/stanu; masowe operacje ZAWSZE zbiorczo, >50 zmian/60s kolapsuje do bulk, dedup per item w oknie
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - type: object
                  properties:
                    type: { const: catalog.updated }
                    data:
                      oneOf:
                        - type: object
                          required: [warehouseItemId, changeType]
                          properties:
                            warehouseItemId: { type: string, format: uuid }
                            sku: { type: ['string', 'null'] }
                            changeType: { type: string, enum: [created, updated, deactivated] }
                            stockQuantity: { type: ['integer', 'null'] }
                        - type: object
                          required: [bulk, itemsChanged, source]
                          properties:
                            bulk: { const: true }
                            source: { type: string, description: 'np. wholesale-import, api-burst' }
                            itemsChanged: { type: integer }
      responses:
        '200': { description: Odbiorca potwierdza (dowolny 2xx w <= 5 s) }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'Authorization: Bearer nf_live_... (primary). Redagowane we wszystkich logach.'
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Alias dla narzedzi bez wsparcia Bearer. Redagowany w logach (Caddy filter + Pino + Sentry).

  headers:
    XRequestId:
      description: Id requestu do korelacji z supportem (echo klienckiego tylko po sanityzacji, max 64 znaki [A-Za-z0-9_-])
      schema: { type: string }
    XRateLimitLimit:
      description: Limit planu (agregat per tenant, req/min)
      schema: { type: integer }
    XRateLimitRemaining:
      description: Pozostale tokeny w oknie
      schema: { type: integer }
    XRateLimitReset:
      description: Epoch seconds pelnego odnowienia bucketa
      schema: { type: integer }

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Cursor:
      name: cursor
      in: query
      schema: { type: string, maxLength: 500 }
      description: Opaque keyset cursor z pagination.nextCursor; walidowany server-side, nieprawidlowy -> 422
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    UpdatedSince:
      name: updatedSince
      in: query
      schema: { type: string, format: date-time }
      description: 'Rekordy z updatedAt >= (inclusive, ms; null updatedAt liczone jak createdAt). Pull-sync: odpytuj z oknem nakladki i deduplikuj po id.'
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 255 }
      description: Dedup 24h per (klucz API, metoda+sciezka, wartosc); body porownywane po SHA-256; inne body -> 409; in-flight -> 409 idempotency_in_progress
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 255 }

  responses:
    Unauthorized:
      description: Brak / zly / odwolany klucz (takze klucz innego tenanta)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Brak scope lub plan bez API / subskrypcja wygasla
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Zasob nie istnieje w tym tenancie
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ValidationFailed:
      description: Bledne dane wejsciowe / zly cursor / niedozwolone przejscie / bundle albo lokalizacje przy stock
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    IdempotencyConflict:
      description: Ten sam Idempotency-Key z innym body (idempotency_conflict) lub duplikat in-flight (idempotency_in_progress)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PayloadTooLarge:
      description: Body > 2 MB
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnsupportedMediaType:
      description: Zapis bez Content-Type application/json
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Przekroczony limit (per tenant / per IP pre-auth / in-flight)
      headers:
        Retry-After: { schema: { type: integer } }
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Money:
      type: string
      pattern: '^-?\d+\.\d{2}$'
      description: Kwota dziesietna jako string (np. "129.99")
    MoneyNonNegative:
      type: string
      pattern: '^\d+\.\d{2}$'
      description: Kwota nieujemna jako string

    Problem:
      type: object
      description: RFC 9457; kazdy Problem niesie requestId
      required: [title, status, code, requestId]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string, description: Komunikat po polsku }
        code:
          type: string
          enum: [unauthorized, missing_scope, plan_limit, not_found, idempotency_conflict, idempotency_in_progress, payload_too_large, unsupported_media_type, validation_failed, rate_limited, internal, service_unavailable]
        requestId: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              message: { type: string }
      examples:
        - type: https://navyflame.com/dokumentacja/api/bledy#validation_failed
          title: Walidacja nie powiodla sie
          status: 422
          detail: Pole lineItems musi zawierac co najmniej jedna pozycje.
          code: validation_failed
          requestId: req_7f3a1b
          errors: [{ field: lineItems, message: Wymagana co najmniej jedna pozycja. }]

    Pagination:
      type: object
      required: [nextCursor, hasMore]
      properties:
        nextCursor: { type: ['string', 'null'] }
        hasMore: { type: boolean }

    Me:
      type: object
      required: [tenant, keyPrefix, scopes, plan, rateLimitPerMin, apiVersion]
      properties:
        tenant: { type: string, description: slug tenanta }
        keyPrefix: { type: string, description: 'np. nf_live_abc123def456' }
        scopes: { type: array, items: { type: string } }
        plan: { type: string, description: 'np. basic, professional, enterprise' }
        rateLimitPerMin: { type: integer, description: limit planu (agregat per tenant) }
        apiVersion: { type: string, const: v1 }

    Address:
      type: object
      description: |
        Adres w ksztalcie publicznym (serializer mapuje wewnetrzny zip -> postalCode).
        Imie/telefon NIE sa czescia adresu (zyja w customer/recipient). Klucze zalezne od zrodla.
      additionalProperties: true
      properties:
        company: { type: ['string', 'null'], maxLength: 200 }
        street: { type: ['string', 'null'], maxLength: 200 }
        buildingNumber: { type: ['string', 'null'], maxLength: 20 }
        city: { type: ['string', 'null'], maxLength: 200 }
        postalCode: { type: ['string', 'null'], maxLength: 20 }
        province: { type: ['string', 'null'], maxLength: 100 }
        country: { type: ['string', 'null'], maxLength: 2, description: ISO 3166-1 alpha-2 gdy znany }

    Customer:
      type: object
      properties:
        name: { type: ['string', 'null'], maxLength: 200 }
        email: { type: ['string', 'null'], maxLength: 320 }
        phone: { type: ['string', 'null'], maxLength: 50 }
        taxId: { type: ['string', 'null'], maxLength: 20, description: NIP }

    PickupPoint:
      type: object
      properties:
        id: { type: ['string', 'null'], maxLength: 100 }
        name: { type: ['string', 'null'], maxLength: 200 }
        description: { type: ['string', 'null'], maxLength: 500 }
        address: { type: ['string', 'null'], maxLength: 500 }

    OrderLineItem:
      type: object
      required: [id, title, quantity, unitPrice, totalPrice]
      properties:
        id: { type: string, format: uuid }
        title: { type: string, maxLength: 500 }
        sku: { type: ['string', 'null'], maxLength: 100 }
        variantTitle: { type: ['string', 'null'], maxLength: 200 }
        quantity: { type: integer }
        unitPrice: { $ref: '#/components/schemas/Money' }
        totalPrice: { $ref: '#/components/schemas/Money' }
        taxRate: { type: ['string', 'null'], description: 'np. "23"' }

    ShippingLine:
      type: object
      required: [id, title, price]
      properties:
        id: { type: string, format: uuid }
        title: { type: string, maxLength: 200 }
        price: { $ref: '#/components/schemas/Money' }

    InvoiceSummary:
      type: object
      required: [id]
      properties:
        id: { type: string, format: uuid }
        number: { type: ['string', 'null'] }
        status: { type: ['string', 'null'] }

    Order:
      type: object
      required: [id, orderNumber, externalOrderId, source, status, paymentStatus, fulfillmentStatus, subtotal, totalTax, totalShipping, totalPrice, currency, createdAt]
      properties:
        id: { type: string, format: uuid }
        orderNumber: { type: string }
        externalOrderId: { type: string }
        source: { type: string, description: 'np. allegro, shopify, woocommerce, api' }
        sourceDomain: { type: ['string', 'null'] }
        status: { type: string, enum: [New, Processing, Completed, Cancelled] }
        paymentStatus: { type: string, enum: [Unpaid, Paid, Refunded, PartiallyRefunded] }
        fulfillmentStatus: { type: string, enum: [Unfulfilled, PartiallyFulfilled, Fulfilled] }
        customer: { $ref: '#/components/schemas/Customer' }
        billingAddress:
          oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }]
        shippingAddress:
          oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }]
        deliveryMethod: { type: ['string', 'null'] }
        pickupPoint:
          oneOf: [{ $ref: '#/components/schemas/PickupPoint' }, { type: 'null' }]
        subtotal: { $ref: '#/components/schemas/Money' }
        totalTax: { $ref: '#/components/schemas/Money' }
        totalShipping: { $ref: '#/components/schemas/Money' }
        totalPrice: { $ref: '#/components/schemas/Money' }
        currency: { type: string }
        orderDate: { type: ['string', 'null'], format: date-time }
        paidAt: { type: ['string', 'null'], format: date-time }
        notes: { type: ['string', 'null'], maxLength: 5000 }
        tags: { type: ['string', 'null'], maxLength: 1000, description: CSV }
        lineItems: { type: array, items: { $ref: '#/components/schemas/OrderLineItem' } }
        shippingLines: { type: array, items: { $ref: '#/components/schemas/ShippingLine' } }
        invoices: { type: array, items: { $ref: '#/components/schemas/InvoiceSummary' } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: ['string', 'null'], format: date-time }

    CreateOrderRequest:
      type: object
      required: [customer, lineItems]
      properties:
        customer:
          allOf: [{ $ref: '#/components/schemas/Customer' }]
          description: Wymagane customer.name
        shippingAddress:
          oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }]
        billingAddress:
          oneOf: [{ $ref: '#/components/schemas/Address' }, { type: 'null' }]
        lineItems:
          type: array
          minItems: 1
          maxItems: 250
          items:
            type: object
            required: [title, quantity, unitPrice]
            properties:
              title: { type: string, maxLength: 500 }
              sku: { type: ['string', 'null'], maxLength: 100, description: Jesli istnieje w magazynie, pozycja linkuje sie do produktu (bez zdjecia stanu w v1) }
              quantity: { type: integer, minimum: 1 }
              unitPrice: { $ref: '#/components/schemas/MoneyNonNegative' }
              taxRate: { type: ['string', 'null'], enum: ['0', '5', '8', '23', null], description: stawka VAT PL }
        shippingLine:
          type: ['object', 'null']
          properties:
            title: { type: string, maxLength: 200 }
            price: { $ref: '#/components/schemas/MoneyNonNegative' }
        deliveryMethod: { type: ['string', 'null'], maxLength: 200 }
        pickupPoint:
          oneOf: [{ $ref: '#/components/schemas/PickupPoint' }, { type: 'null' }]
        currency: { type: string, pattern: '^[A-Z]{3}$', default: PLN }
        orderDate: { type: string, format: date-time, description: 'domyslnie teraz; MUSI zawierac strefe (Z/offset)' }
        paymentStatus: { type: string, enum: [Unpaid, Paid], default: Unpaid }
        notes: { type: ['string', 'null'], maxLength: 5000 }
        tags: { type: ['string', 'null'], maxLength: 1000 }
        suppressAutomations:
          type: boolean
          default: false
          description: true = tylko zapis, bez automatyzacji; fakturowanie mozna pozniej wyzwolic przez POST /orders/{id}/invoice

    Variant:
      type: object
      required: [id, title, price, position]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        sku: { type: ['string', 'null'] }
        barcode: { type: ['string', 'null'] }
        price: { $ref: '#/components/schemas/Money' }
        compareAtPrice:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        stockQuantity: { type: ['integer', 'null'] }
        options:
          type: array
          items:
            type: object
            required: [name, value]
            properties:
              name: { type: string }
              value: { type: string }
        position: { type: integer }

    WarehouseItem:
      type: object
      required: [id, title, status, currency, trackInventory, stockMode, reservedQuantity, availableQuantity, allowBackorder, locationManaged, isBundle, createdAt]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        description: { type: ['string', 'null'] }
        vendor: { type: ['string', 'null'] }
        productType: { type: ['string', 'null'] }
        sku: { type: ['string', 'null'] }
        barcode: { type: ['string', 'null'] }
        status: { type: string, enum: [Active, Draft, Archived] }
        price:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        compareAtPrice:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        currency: { type: string }
        cost:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
          description: Koszt jednostkowy (COGS) - dane wlasne sprzedawcy
        costCurrency: { type: string }
        weight: { type: ['string', 'null'], description: Decimal jako string }
        weightUnit: { type: ['string', 'null'] }
        stockQuantity: { type: ['integer', 'null'] }
        reservedQuantity: { type: integer }
        availableQuantity: { type: integer, description: '(stockQuantity ?? 0) - reservedQuantity - zawsze integer, jak w kodzie IE' }
        trackInventory: { type: boolean }
        stockMode: { type: string, enum: [Shared, Reserved] }
        allowBackorder: { type: boolean }
        locationManaged: { type: boolean, description: 'true = stan per lokalizacja (stockQuantity derywowane); PATCH /stock -> 422' }
        isBundle: { type: boolean, description: 'true = zestaw (stan wyliczany z komponentow); PATCH /stock -> 422' }
        images: { type: array, items: { type: string, format: uri } }
        tags: { type: ['string', 'null'] }
        language: { type: string }
        variants: { type: array, items: { $ref: '#/components/schemas/Variant' } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: ['string', 'null'], format: date-time }

    Contractor:
      type: object
      required: [name]
      properties:
        name: { type: string }
        taxId: { type: ['string', 'null'] }
        address: { type: ['string', 'null'] }
        email: { type: ['string', 'null'] }

    InvoiceItem:
      type: object
      required: [id, name, quantity, unit, unitPrice, taxRate]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        sku: { type: ['string', 'null'] }
        quantity: { type: string, description: Decimal jako string (np. "2.0000") }
        unit: { type: string }
        unitPrice: { $ref: '#/components/schemas/Money' }
        taxRate: { type: string }

    Invoice:
      type: object
      required: [id, number, type, status, issueDate, saleDate, contractor, netAmount, taxAmount, grossAmount, currency, createdAt]
      properties:
        id: { type: string, format: uuid }
        number: { type: string }
        type: { type: string, enum: [Invoice, CreditNote, Proforma] }
        status: { type: string, enum: [Draft, Issued, Sent, Paid, Cancelled] }
        issueDate: { type: string, format: date-time }
        saleDate: { type: string, format: date-time }
        dueDate: { type: ['string', 'null'], format: date-time }
        paymentDate: { type: ['string', 'null'], format: date-time }
        contractor: { $ref: '#/components/schemas/Contractor' }
        orderId: { type: ['string', 'null'], format: uuid }
        externalOrderId: { type: ['string', 'null'] }
        channelName: { type: ['string', 'null'] }
        netAmount: { $ref: '#/components/schemas/Money' }
        taxAmount: { $ref: '#/components/schemas/Money' }
        grossAmount: { $ref: '#/components/schemas/Money' }
        currency: { type: string }
        accountingProvider: { type: ['string', 'null'], description: 'np. wfirma, fakturownia' }
        externalAccountingId: { type: ['string', 'null'] }
        syncedAt: { type: ['string', 'null'], format: date-time }
        ksef:
          type: object
          properties:
            status: { type: ['string', 'null'], enum: [None, Pending, Sent, Accepted, Rejected, null] }
            referenceNumber: { type: ['string', 'null'] }
        items: { type: array, items: { $ref: '#/components/schemas/InvoiceItem' } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: ['string', 'null'], format: date-time }

    ShipmentEvent:
      type: object
      required: [status, occurredAt]
      properties:
        status: { type: string }
        description: { type: ['string', 'null'] }
        location: { type: ['string', 'null'] }
        occurredAt: { type: string, format: date-time }

    Shipment:
      type: object
      required: [id, orderId, courierProvider, courierService, status, recipient, currency, createdAt]
      properties:
        id: { type: string, format: uuid }
        orderId: { type: string, format: uuid }
        externalShipmentId: { type: ['string', 'null'] }
        courierProvider: { type: string, description: 'np. inpost, dpd, dhl, gls, orlen' }
        courierService: { type: string }
        status: { type: string, enum: [Draft, LabelRequested, LabelReady, PickedUp, InTransit, Delivered, Returned, Cancelled] }
        trackingNumber: { type: ['string', 'null'] }
        trackingUrl: { type: ['string', 'null'], format: uri }
        recipient:
          type: object
          required: [name, phone]
          properties:
            name: { type: string }
            phone: { type: string }
            email: { type: ['string', 'null'] }
        recipientAddress: { $ref: '#/components/schemas/Address' }
        parcelSize: { type: ['string', 'null'] }
        weightKg: { type: ['string', 'null'] }
        lengthCm: { type: ['string', 'null'] }
        widthCm: { type: ['string', 'null'] }
        heightCm: { type: ['string', 'null'] }
        targetPointCode: { type: ['string', 'null'], description: Kod punktu / paczkomatu }
        shippingCost:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        codAmount:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        insurance:
          oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }]
        currency: { type: string }
        reference: { type: ['string', 'null'] }
        content: { type: ['string', 'null'] }
        events: { type: array, items: { $ref: '#/components/schemas/ShipmentEvent' } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: ['string', 'null'], format: date-time }
        pickedUpAt: { type: ['string', 'null'], format: date-time }
        deliveredAt: { type: ['string', 'null'], format: date-time }

    WebhookEnvelope:
      type: object
      required: [id, type, apiVersion, occurredAt, tenant, origin, data]
      properties:
        id: { type: string, description: 'evt_<uuid> - deduplikacja po stronie odbiorcy' }
        type: { type: string, enum: [order.synced, invoice.issued, shipment.status_updated, catalog.updated] }
        apiVersion: { type: string, const: v1 }
        occurredAt: { type: string, format: date-time }
        tenant: { type: string, description: slug tenanta (niezmienny) }
        origin:
          type: object
          required: [channel]
          description: Zrodlo zmiany - pozwala odbiorcy przerwac petle echa (ERP ignoruje zdarzenia wywolane wlasnym kluczem)
          properties:
            channel: { type: string, enum: [api, panel, sync, automation] }
            apiKeyPrefix: { type: ['string', 'null'], description: 'prefix klucza (nf_live_abc123), tylko gdy channel=api' }
        data: { type: object }
