openapi: 3.1.0
info:
  title: Premium Supply Seller API
  version: 1.0.0
  summary: API dla sprzedawców marketplace premiumsupply.pl
  description: |
    Publiczne API dla sprzedawców i integratorów. Jeden kontrakt, wersja w ścieżce (`/v1/`),
    uwierzytelnianie OAuth 2.0, limity zapytań podane w nagłówkach każdej odpowiedzi.

    ## Zasady wspólne dla wszystkich zasobów `/v1`

    **Nagłówki żądania**
    - `Authorization: Bearer <token>` — wymagany.
    - `User-Agent: NazwaAplikacji/Wersja (+https://adres-dokumentacji)` — część `Nazwa/Wersja`
      jest **wymagana** (bez niej `400 missing_user_agent`), część w nawiasie zalecana. Nazwa powinna
      odpowiadać nazwie zarejestrowanej aplikacji.
    - `Accept: application/json` — zalecany; API zawsze odpowiada JSON.

    **Nagłówki odpowiedzi (każda odpowiedź `/v1`)**
    - `X-Trace-Id` — identyfikator żądania; podaj go w zgłoszeniu do wsparcia.
    - `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch s) — limit liczony
      **per aplikacja**, domyślnie 600 żądań/min.
    - `Retry-After` przy `429`.
    - `Cache-Control: no-store` — odpowiedzi niosą dane kupujących.

    **Idempotencja**: każde `POST`/`PATCH`/`DELETE` może nieść `Idempotency-Key` (≤ 128 znaków).
    Powtórzone żądanie z tym samym kluczem **w ciągu 24 h, od tego samego sprzedawcy, z tą samą
    metodą, ścieżką i ciałem** zwraca zapisaną odpowiedź z nagłówkiem `Idempotent-Replayed: true`.
    Ten sam klucz z innym ciałem → `422 idempotency_key_reused`; żądanie w trakcie wykonywania →
    `409 idempotency_in_progress` (spróbuj ponownie po `Retry-After`).

    **Paginacja**: kursorowa. `?limit=50&after=<cursor>`; odpowiedź zawiera `next` (kursor albo
    `null`). `limit` 1–200 (wartości poza zakresem są przycinane), domyślnie 50. Nieprawidłowy
    kursor → `400 validation_error`.

    **Wybór pól**: `?fields=id,title,price` ogranicza zwracane pola; `id` jest zwracane zawsze.

    **Błędy**: zasoby `/v1` zawsze zwracają jeden kształt — schemat `Error`. Pole `code` jest
    stabilne i udokumentowane na https://developer.premiumsupply.pl/errors; `message` jest dla
    programisty, `userMessage` (jeśli jest) można pokazać sprzedawcy. Wyjątkiem są punkty OAuth
    (`/oauth/*`), które — zgodnie z RFC 6749 — używają schematu `OAuthError` (`error`,
    `error_description`) uzupełnionego o `traceId`.
  contact:
    name: Premium Supply — wsparcie API
    url: https://developer.premiumsupply.pl/kontakt
    email: api@premiumsupply.pl
  license:
    name: Regulamin API Premium Supply
    url: https://developer.premiumsupply.pl/regulamin

servers:
  - url: https://api.premiumsupply.pl/v1
    description: Produkcja
  - url: https://api.sandbox.premiumsupply.pl/v1
    description: Sandbox (planowany)

tags:
  - name: Uwierzytelnianie
    description: OAuth 2.0 — `client_credentials` dla własnych skryptów, `authorization_code` + PKCE dla integratorów.
  - name: Konto
  - name: Oferty
    description: Produkty sprzedawcy wystawione w marketplace.
  - name: Zamówienia
  - name: Przesyłki
  - name: Webhooki
    description: |
      Powiadomienia push o zdarzeniach. Zarządzanie subskrypcjami wymaga zakresów `settings:read`
      (odczyt) i `settings:write` (tworzenie, usuwanie) — subskrypcja to konfiguracja integracji,
      nie dane domenowe. Podpis HMAC-SHA256 w nagłówku `X-Signature`.

security:
  - bearer: []

paths:
  # ───────────────────────────── OAuth ─────────────────────────────
  /oauth/token:
    servers:
      - url: https://api.premiumsupply.pl
    post:
      tags: [Uwierzytelnianie]
      security: []
      summary: Wydanie tokenu
      description: |
        Trzy tryby (`grant_type`):
        - `client_credentials` — token dla konta właściciela aplikacji. Uwierzytelnienie
          `Authorization: Basic base64(client_id:client_secret)` albo `client_id`/`client_secret` w ciele.
          Działa także dla aplikacji w statusie `pending` (czeka na akceptację) — to własne konto.
        - `authorization_code` — po zgodzie sprzedawcy (`/oauth/authorize`). Wymaga `code`,
          `redirect_uri` (identyczny jak przy autoryzacji), `code_verifier` (PKCE S256, 43–128 znaków).
          Klient publiczny może pominąć sekret; jeśli go poda, jest weryfikowany.
        - `refresh_token` — wymiana tokenu odświeżającego; stary traci ważność (**rotacja**).
          Klient poufny uwierzytelnia się jak w `client_credentials`; token musi należeć do tej
          aplikacji. Ponowne użycie już zużytego tokenu odświeżającego odwołuje całą rodzinę tokenów.

        Token dostępowy jest ważny **12 godzin**, odświeżający **90 dni**. Zakresy nowej pary to
        przecięcie zakresów tokenu z **aktualnymi** zakresami aplikacji.

        Limit: 30 żądań/min per IP i 60/min per `client_id` (`429` z `Retry-After`).
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TokenRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/TokenRequest" }
      responses:
        "200":
          description: "Token wydany. Nagłówek `X-Trace-Id` obecny, `Cache-Control: no-store`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: "`invalid_request`, `invalid_grant`, `invalid_scope`, `unsupported_grant_type`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: "`invalid_client` — nieprawidłowe `client_id`/`client_secret` albo aplikacja zawieszona"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "429":
          description: "`rate_limited`"
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/authorize:
    servers:
      - url: https://api.premiumsupply.pl
    get:
      tags: [Uwierzytelnianie]
      security: []
      summary: Początek zgody sprzedawcy
      description: |
        Przekierowuje sprzedawcę na ekran zgody (`https://premiumsupply.pl/oauth/consent`). Po
        akceptacji sprzedawca trafia na `redirect_uri` z parametrami `code` i `state`; po odmowie —
        z `error=access_denied` i `state`. Kod jest ważny 10 minut i jednorazowy.

        Zgodnie z RFC 6749: gdy `client_id` lub `redirect_uri` są nieprawidłowe, odpowiedź to `400`
        (bez przekierowania). Pozostałe błędy wracają na `redirect_uri` jako `error` ∈
        {`unsupported_response_type`, `invalid_request`, `invalid_scope`, `access_denied`}.

        Aplikacja zarejestrowana „na własne potrzeby" może prosić o zgodę wyłącznie własnego konta.
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri }, description: Musi być jednym z adresów zarejestrowanych w aplikacji. }
        - { name: scope, in: query, required: true, schema: { type: string }, description: Zakresy rozdzielone spacjami. }
        - { name: state, in: query, required: true, schema: { type: string, minLength: 8 } }
        - { name: code_challenge, in: query, required: true, schema: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
      responses:
        "302": { description: Przekierowanie — na ekran zgody albo z powrotem na `redirect_uri` z `error`. }
        "400":
          description: "`invalid_client` albo `invalid_request` (redirect_uri niezarejestrowany)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "429":
          description: "`rate_limited`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  # ───────────────────────────── Konto ─────────────────────────────
  /me:
    get:
      tags: [Konto]
      summary: Kim jestem
      description: Sprzedawca, aplikacja i zakresy, na które wystawiony jest bieżący token. Dobre pierwsze wywołanie; nie wymaga zakresu.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ───────────────────────────── Oferty ─────────────────────────────
  /offers:
    get:
      tags: [Oferty]
      summary: Lista ofert sprzedawcy
      description: Wymaga zakresu `offers:read`.
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/after"
        - $ref: "#/components/parameters/fields"
        - name: status
          in: query
          schema: { type: string, enum: [published, draft, proposed, rejected] }
        - name: updated_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, next]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Offer" }
                  next: { type: [string, "null"], description: Kursor kolejnej strony. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Oferty]
      summary: Szczegóły oferty
      description: Wymaga zakresu `offers:read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Oferty]
      summary: Zmiana ceny, stanu lub statusu
      description: |
        Wymaga zakresu `offers:write`. Zmienia tylko podane pola. Zalecany nagłówek `Idempotency-Key`.
        Ręcznie ustawiona cena blokuje automat wycen (`price_source = manual`).
        Status można przełączać tylko między `published` i `draft`; oferta w stanie `proposed` lub
        `rejected` jest w moderacji i zwraca `422 status_locked_by_moderation`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                price: { $ref: "#/components/schemas/Money" }
                stock: { type: integer, minimum: 0 }
                status: { type: string, enum: [published, draft] }
      responses:
        "200":
          description: Zaktualizowano
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyInProgress" }
        "422":
          description: "`no_price_row`, `no_inventory_row`, `status_locked_by_moderation`, `idempotency_key_reused`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ───────────────────────────── Zamówienia ─────────────────────────────
  /orders:
    get:
      tags: [Zamówienia]
      summary: Zamówienia zawierające towar sprzedawcy
      description: |
        Wymaga zakresu `orders:read`. W zamówieniu wielosprzedawcowym widzisz **tylko swoje pozycje**;
        `status` jest agregatem twoich pozycji. Zamówienia testowe nie są zwracane.
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/after"
        - $ref: "#/components/parameters/fields"
        - name: status
          in: query
          description: Status zamówienia z perspektywy sprzedawcy.
          schema: { type: string, enum: [pending, processing, shipped, delivered, cancelled] }
        - name: placed_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, next]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  next: { type: [string, "null"] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: Identyfikator albo numer zamówienia (np. `PS362496085`). }
    get:
      tags: [Zamówienia]
      summary: Szczegóły zamówienia
      description: Wymaga zakresu `orders:read`. Dane kupującego i adres są udostępniane od momentu opłacenia (`payment_status = paid`).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders/{id}/shipments:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string }, description: Identyfikator albo numer zamówienia. }
    post:
      tags: [Przesyłki]
      summary: Zgłoszenie wysyłki
      description: |
        Wymaga zakresu `shipments:write`. Zapisuje przewoźnika i numer, przenosi zamówienie w stan
        `shipped`. Przed powiadomieniem kupującego API pyta przewoźnika o status: jeśli paczka jest
        już doręczona, zapisuje doręczenie i **nie** wysyła maila „w drodze" (`delivered_already: true`).
        Operacja nieodwracalna — `Idempotency-Key` jest **zalecany**.

        Dziś przesyłka obejmuje całe zamówienie: zamówienie zawierające towar innego sprzedawcy zwraca
        `409 multi_seller_order`. Pole `item_ids` jest walidowane (muszą to być twoje pozycje), ale
        nie ogranicza jeszcze zakresu wysyłki.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [carrier, tracking_number]
              properties:
                carrier: { type: string, enum: [inpost, dhl, poczta_polska, dpd, gls, ups, fedex, other] }
                tracking_number: { type: string, minLength: 4, maxLength: 64, pattern: "^[A-Za-z0-9-]+$" }
                item_ids:
                  type: array
                  description: Identyfikatory pozycji (= identyfikatory ofert); brak = wszystkie twoje pozycje.
                  items: { type: string }
      responses:
        "201":
          description: Przesyłka zapisana
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Shipment" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`already_shipped`, `order_unpaid`, `order_cancelled`, `multi_seller_order`, `idempotency_in_progress`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: "`idempotency_key_reused`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ───────────────────────────── Webhooki ─────────────────────────────
  /webhooks:
    get:
      tags: [Webhooki]
      summary: Lista subskrypcji
      description: Wymaga zakresu `settings:read`. Zwraca subskrypcje tej aplikacji dla tego sprzedawcy.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, available_events]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
                  available_events:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEvent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Webhooki]
      summary: Utworzenie subskrypcji
      description: |
        Wymaga zakresu `settings:write`. Maksymalnie 20 subskrypcji na parę (aplikacja, sprzedawca).

        Każde dostarczenie to `POST` JSON z nagłówkami `X-Signature: sha256=<hex>` (HMAC-SHA256 z surowych
        bajtów ciała, kluczem `secret` zwróconym tu jednorazowo), `X-Event`, `X-Delivery-Id` (stały przy
        ponowieniach — to samo ciało i podpis) oraz `User-Agent: premiumsupply-webhooks/1.0 (+https://developer.premiumsupply.pl/webhooki)`.
        Dowolna odpowiedź 2xx = dostarczone. Ponowienia po 1 min, 5 min, 30 min, 2 h, 12 h
        (łącznie 6 prób); przekierowania nie są śledzone. Adres musi być HTTPS i rozwiązywać się na
        adres publiczny — adresy prywatne są odrzucane także w chwili dostarczenia.

        Ciało jest „cienkie": `{ id, event, occurred_at, data }`, gdzie `data` dla `order.*` =
        `{ order_id, order_number }`, dla `offer.rejected` = `{ offer_id }`. Po szczegóły sięgnij
        `GET /v1/orders/{id}` — dane osobowe kupującego nie są wysyłane w webhooku.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri, description: Wyłącznie HTTPS, host publiczny. }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "201":
          description: Utworzono. Pole `secret` pojawia się tylko w tej odpowiedzi.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/IdempotencyInProgress" }
        "422":
          description: "`webhook_limit_reached` (limit 20), `idempotency_key_reused`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Webhooki]
      summary: Usunięcie subskrypcji
      description: Wymaga zakresu `settings:write`.
      responses:
        "204": { description: Usunięto }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}/deliveries:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Webhooki]
      summary: Historia dostarczeń
      description: |
        Wymaga zakresu `settings:read`. Ostatnie 100 dostarczeń z kodem odpowiedzi i czasem — to samo
        widać w panelu. Dostawa „wyczerpana" = `delivered_at` null ∧ `next_retry_at` null ∧ `attempt` = 6.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: Token z `/oauth/token`.

  parameters:
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      description: Wartości poza zakresem są przycinane do 1–200.
    after:
      name: after
      in: query
      schema: { type: string }
      description: Kursor z pola `next` poprzedniej odpowiedzi. Nieprawidłowy → `400 validation_error`.
    fields:
      name: fields
      in: query
      schema: { type: string }
      description: Lista pól rozdzielona przecinkami; `id` jest zwracane zawsze.
    idempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 128 }

  responses:
    BadRequest:
      description: "Nieprawidłowe żądanie: `missing_user_agent`, `validation_error`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: "`unauthorized` — brak, nieważny lub odwołany token; aplikacja zawieszona"
      headers:
        WWW-Authenticate: { schema: { type: string } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "`insufficient_scope` — token nie ma wymaganego zakresu (zakresy tokenu są przecinane z aktualnymi zakresami aplikacji)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: "`not_found` — zasób nie istnieje albo nie należy do sprzedawcy"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IdempotencyInProgress:
      description: "`idempotency_in_progress` — żądanie z tym kluczem jest jeszcze wykonywane"
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: "`rate_limited` — przekroczony limit aplikacji; patrz `Retry-After`"
      headers:
        Retry-After: { schema: { type: integer } }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalError:
      description: "`internal_error` — podaj `X-Trace-Id` w zgłoszeniu"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    ErrorCode:
      type: string
      description: Stabilny identyfikator błędu — pełna lista na https://developer.premiumsupply.pl/errors
      enum:
        - missing_user_agent
        - unauthorized
        - insufficient_scope
        - not_found
        - validation_error
        - rate_limited
        - internal_error
        - idempotency_key_reused
        - idempotency_in_progress
        - no_price_row
        - no_inventory_row
        - status_locked_by_moderation
        - already_shipped
        - order_unpaid
        - order_cancelled
        - multi_seller_order
        - webhook_limit_reached

    Error:
      type: object
      required: [errors, traceId]
      properties:
        traceId: { type: string, example: "b7f3c2e1-4d9a-4c1e-9f2a-1d3e5f7a9b0c" }
        errors:
          type: array
          items:
            type: object
            required: [code, message]
            properties:
              code: { $ref: "#/components/schemas/ErrorCode" }
              message: { type: string, description: Dla programisty, po angielsku. }
              userMessage: { type: string, description: Do pokazania sprzedawcy, po polsku. }
              path: { type: string, description: Ścieżka pola w żądaniu, jeśli dotyczy. }

    OAuthError:
      type: object
      description: Błąd punktów OAuth wg RFC 6749, uzupełniony o `traceId`.
      required: [error, error_description, traceId]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant, invalid_scope, unsupported_grant_type, unsupported_response_type, access_denied, rate_limited, server_error]
        error_description: { type: string }
        traceId: { type: string }

    TokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [client_credentials, authorization_code, refresh_token]
        code: { type: string }
        redirect_uri: { type: string, format: uri }
        code_verifier: { type: string, minLength: 43, maxLength: 128, pattern: "^[A-Za-z0-9._~-]+$" }
        refresh_token: { type: string }
        client_id: { type: string, description: Alternatywnie do nagłówka Basic. }
        client_secret: { type: string }
        scope: { type: string, description: Podzbiór zakresów aplikacji, rozdzielony spacjami (tylko `client_credentials`). }

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token: { type: string }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 43200 }
        refresh_token: { type: string, description: Tylko dla `authorization_code` i `refresh_token`. }
        scope: { type: string }

    Me:
      type: object
      required: [seller, app, scopes, token]
      properties:
        seller:
          type: object
          properties:
            id: { type: string }
            seller_no: { type: [integer, "null"] }
        app:
          type: object
          properties:
            client_id: { type: string }
            name: { type: string }
        scopes: { type: array, items: { type: string } }
        token:
          type: object
          properties:
            kind: { type: string, enum: [access, personal] }
            expires_at: { type: [string, "null"], format: date-time, description: "`null` dla tokenu osobistego bez wygasania." }

    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: { type: integer, description: W najmniejszej jednostce (grosze). }
        currency: { type: string, example: PLN }

    Offer:
      type: object
      required: [id, title, status, price, url]
      properties:
        id: { type: string }
        title: { type: string }
        handle: { type: string }
        status: { type: string, enum: [published, draft, proposed, rejected] }
        price:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: "`null` = oferta bez ceny w PLN."
        stock: { type: [integer, "null"], description: "`null` = brak poziomu magazynowego." }
        ean: { type: [string, "null"] }
        brand: { type: [string, "null"] }
        signature: { type: [string, "null"], description: Sygnatura `ASIN|Marka|EAN` używana przy publikacji na Allegro. }
        thumbnail: { type: [string, "null"], format: uri }
        url: { type: string, format: uri }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OrderItem:
      type: object
      required: [id, offer_id, quantity, fulfillment_status]
      properties:
        id: { type: string, description: Dziś równe `offer_id`. }
        offer_id: { type: string }
        title: { type: [string, "null"] }
        handle: { type: [string, "null"] }
        sku: { type: [string, "null"] }
        quantity: { type: integer }
        unit_price:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
        fulfillment_status:
          type: string
          enum: [pending, processing, shipped, delivered, cancelled]
          description: Dziś wysyłka jest na poziomie zamówienia, więc równy statusowi zamówienia.

    Order:
      type: object
      required: [id, order_number, status, payment_status, placed_at, items]
      properties:
        id: { type: string }
        order_number: { type: string }
        status: { type: string, enum: [pending, processing, shipped, delivered, cancelled], description: Agregat twoich pozycji. }
        payment_status: { type: string, enum: [awaiting_payment, paid, cancelled] }
        payment_method: { type: [string, "null"], example: p24_blik }
        placed_at: { type: string, format: date-time }
        paid_at: { type: [string, "null"], format: date-time }
        shipped_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        delivery_method: { type: [string, "null"] }
        shipment:
          type: [object, "null"]
          properties:
            carrier: { type: [string, "null"], description: Nazwa przewoźnika, np. `InPost`. }
            tracking_number: { type: string }
            tracking_url: { type: [string, "null"], format: uri }
            tracking_status: { type: [string, "null"] }
        buyer:
          type: [object, "null"]
          description: Widoczne po opłaceniu (`payment_status = paid`); inaczej `null`.
          properties:
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
            phone: { type: [string, "null"] }
        shipping_address:
          type: [object, "null"]
          description: Widoczne po opłaceniu; inaczej `null`.
          properties:
            line1: { type: [string, "null"] }
            line2: { type: [string, "null"] }
            postal_code: { type: [string, "null"] }
            city: { type: [string, "null"] }
            country: { type: string, example: PL }
        items:
          type: array
          items: { $ref: "#/components/schemas/OrderItem" }
        is_test: { type: boolean }

    Shipment:
      type: object
      required: [id, order_id, carrier, tracking_number, item_ids, created_at]
      properties:
        id: { type: string }
        order_id: { type: string }
        carrier: { type: string, enum: [inpost, dhl, poczta_polska, dpd, gls, ups, fedex, other] }
        carrier_name: { type: string, example: InPost }
        tracking_number: { type: string }
        tracking_url: { type: [string, "null"], format: uri }
        item_ids: { type: array, items: { type: string } }
        delivered_already: { type: boolean, description: Przewoźnik zgłosił doręczenie — mail „w drodze" pominięty. }
        buyer_notified: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookEvent:
      type: string
      enum: [order.created, order.paid, order.shipped, order.delivered, order.cancelled, offer.rejected]

    Webhook:
      type: object
      required: [id, url, events, active, created_at]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: "#/components/schemas/WebhookEvent" } }
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookCreated:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret: { type: string, description: Pokazywany tylko raz. }

    WebhookDelivery:
      type: object
      required: [id, event, attempt, created_at]
      properties:
        id: { type: string }
        event: { $ref: "#/components/schemas/WebhookEvent" }
        attempt: { type: integer, description: Liczba wykonanych prób (0 tuż po zakolejkowaniu, maks. 6). }
        response_status: { type: [integer, "null"] }
        duration_ms: { type: [integer, "null"] }
        delivered_at: { type: [string, "null"], format: date-time }
        next_retry_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
