openapi: 3.1.0
info:
  title: POSnavigator Device Orders API
  version: "1.0.0"
  description: |-
    Bank-scoped physical terminal orders: list, detail, and a
    discriminated action POST. Requires device_order:read for GET and
    device_order:write for actions. Those scopes are NOT on a default new key —
    grant them in admin. The key owner must be admin or listed on the bank.
    
    Fulfillment status: CART, CHECKOUT_BLOCKED, PAYMENT_PENDING, PAYMENT_FAILED,
    TRANSFER_PENDING, PAID, KYC_REVIEW, BANK_APPROVAL, ADMIN_REVIEW, AUTO_FULFILLING,
    AUTO_FULFILLMENT_ERROR, READY_TO_PACK, PACKED, SHIPPED, DELIVERED, BANK_REJECTED,
    REFUND_PENDING, REFUNDED, PARTIALLY_REFUNDED, CANCELLED, CANCELLED_NO_PAYMENT.
    payment_status: CART, PENDING, FAILED, PAID, CANCELLED, REFUNDED, PARTIALLY_REFUNDED.
    
    Never invent transitions. One action per POST. On INVALID_STATE_TRANSITION re-GET
    detail with include_events=1 and pick a legal next step.
    
    # The happy path
    
    1. GET /openapi/device-orders.yaml
    2. Resolve bankId (GET /api/v1/banks)
    3. GET .../device-orders with status filters
    4. GET .../device-orders/{id}?include_events=1 on anomalies
    5. POST exactly one action
    
    Send `X-Api-Key: <key>` on every operation except this document itself.
    Create a key at /user/api-keys. Format: `pn_` plus a random string. Copy it
    immediately; it is shown once. Default new keys include blog, legal, presetfilter,
    versus-landing, widget, agent-analytics and hero-analytics scopes. Missing scopes
    return 403 INSUFFICIENT_PERMISSIONS. Bank-scoped resources also require the key
    owner to be admin or listed on that bank; otherwise 403 FORBIDDEN.
    
    # Response envelope
    
    Success: `{ "success": true, "data": ... }`.
    Failure: `{ "success": false, "error": { "code", "message" } }`.
    Branch on error.code, never on error.message.
    
    # Error codes
    
    | code | status | what to do |
    | --- | --- | --- |
    | MISSING_API_KEY | 401 | add X-Api-Key; do not retry blindly |
    | INVALID_API_KEY | 401 | stop; ask a human for a new key |
    | INSUFFICIENT_PERMISSIONS | 403 | stop; this key lacks a required scope |
    | FORBIDDEN | 403 | stop or switch to a key with bank/admin access |
    | RATE_LIMIT_EXCEEDED | 429 | wait Retry-After seconds |
    | INTERNAL_ERROR | 500 | retry with backoff |
    
    # Rate limiting
    
    Every response after successful authentication carries X-RateLimit-Limit,
    X-RateLimit-Remaining and X-RateLimit-Reset. A 429 also carries Retry-After.
    The two 401s are emitted before rate-limit accounting. Default window is 100
    requests per rolling hour unless the key was configured otherwise.
    
    # Canonical host
    
    Use https://posnavigator.eu. Legacy .hu hosts redirect; authenticated writes
    should call .eu directly.
    
    Additional codes: INVALID_BANK_ID 400, INVALID_JSON 400, VALIDATION_ERROR 400,
    NOT_FOUND 404, INVALID_STATE_TRANSITION 409, plus fulfillment-layer codes.
servers:
  - url: https://posnavigator.eu
tags:
  - name: Discovery
  - name: DeviceOrders
security:
  - ApiKeyAuth: []
paths:
  /openapi/device-orders.yaml:
    get:
      operationId: getDeviceOrdersOpenApi
      tags: [Discovery]
      security: []
      summary: Public OpenAPI 3.1 description
      description: |-
        This document. Public: no API key, no rate limit. Fetch it first and treat
        it as the complete manual. Every schema, limit, example, error code and
        header an agent needs is here. Linked from GET /openapi/catalog.yaml.
        Data operations below require authentication as described in info.
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
              example: 'openapi: 3.1.0'
  /api/v1/banks/{bankId}/device-orders:
    get:
      operationId: listDeviceOrders
      tags: [DeviceOrders]
      summary: List terminal orders for a bank
      description: |-
        Requires device_order:read and bank access. Optional filters: status and
        payment_status as comma-separated enums, payment_method CARD|BANK_TRANSFER,
        is_test_mode true|false, order_number exact (case-insensitive),
        merchant_search substring, wizard_session_id, linked_bank_order_id,
        date_from/date_to on createdAt, page >=1 default 1, page_size 1-200
        default 50, sort createdAt_desc|createdAt_asc. Invalid linked_bank_order_id
        yields an empty list, not 400.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - name: status
          in: query
          schema: { type: string, maxLength: 400 }
          description: Comma-separated fulfillment statuses
        - name: payment_status
          in: query
          schema: { type: string, maxLength: 200 }
        - name: payment_method
          in: query
          schema: { type: string, enum: [CARD, BANK_TRANSFER] }
        - name: is_test_mode
          in: query
          schema: { type: string, enum: ['true', 'false'] }
        - name: order_number
          in: query
          schema: { type: string, maxLength: 80 }
        - name: merchant_search
          in: query
          schema: { type: string, maxLength: 200 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: page_size
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: sort
          in: query
          schema: { type: string, enum: [createdAt_desc, createdAt_asc] }
      responses:
        '200':
          description: Order page
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceOrderListResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /api/v1/banks/{bankId}/device-orders/{orderId}:
    get:
      operationId: getDeviceOrder
      tags: [DeviceOrders]
      summary: Order detail plus refunds
      description: |-
        Requires device_order:read. Returns order snapshot (merchant, lines,
        review_state, tracking, checkout_block_reason) and refunds newest first.
        include_events=1|true adds chronological events (STATUS_CHANGED, ERROR,
        ADMIN_NOTE, PAYMENT_*, REVIEW_DECISION, SERIAL_*, INVOICE_CREATED,
        LABEL_CREATED, SHIPMENT_UPDATED, REFUND_*, EMAIL_SENT, CART_ANONYMIZED).
      parameters:
        - $ref: '#/components/parameters/BankId'
        - name: orderId
          in: path
          required: true
          schema: { type: string, pattern: '^[a-fA-F0-9]{24}$', maxLength: 24 }
        - name: include_events
          in: query
          schema: { type: string, enum: ['1', 'true', '0', 'false'] }
      responses:
        '200':
          description: Order detail
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceOrderDetailResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /api/v1/banks/{bankId}/device-orders/{orderId}/actions:
    post:
      operationId: executeDeviceOrderAction
      tags: [DeviceOrders]
      summary: Run one fulfillment action
      description: |-
        Requires device_order:write. Discriminated union on action. Exactly one
        action per request. Returns refreshed order + refunds. Actions:
        append_admin_note (note <=8000); clear_transfer_autocancel_suspension;
        suspend_transfer_autocancel_until (suspend_until ISO, optional note);
        confirm_bank_transfer (payload amount, credited_at, bank_reference,
        accept_amount_mismatch, optional note) — amount must match gross unless
        accept_amount_mismatch; apply_review_decision (review_type kyc|bank|admin,
        payload.result APPROVED|REJECTED); retry_automatic_fulfillment;
        create_final_invoice; create_shipment_label; initiate_refund (payload
        refund_type, amount, reason, optional note and release_unit_ids);
        complete_bank_transfer_refund (refund_id + payload.manual_transfer_reference).
      parameters:
        - $ref: '#/components/parameters/BankId'
        - name: orderId
          in: path
          required: true
          schema: { type: string, pattern: '^[a-fA-F0-9]{24}$', maxLength: 24 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceOrderAction'
            example:
              action: append_admin_note
              note: Waiting on merchant KYC documents.
      responses:
        '200':
          description: Updated order
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceOrderDetailResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '409':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |-
        Send `X-Api-Key: pn_...` on every data operation. Create a key at
        /user/api-keys; it is shown once. Inactive or expired keys return 401.
  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix epoch seconds when the window resets
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying a 429
      schema:
        type: integer
  parameters:
    BankId:
      name: bankId
      in: path
      required: true
      description: 24-character hex Mongo ObjectId of the bank
      schema:
        type: string
        pattern: '^[a-fA-F0-9]{24}$'
        maxLength: 24

  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              maxLength: 80
              description: Stable machine code. Branch on this, never on message.
            message:
              type: string
              maxLength: 2000
            details:
              type: array
              maxItems: 50
              items:
                type: string
                maxLength: 500
            retryAfter:
              type: integer
              description: Present on RATE_LIMIT_EXCEEDED
            field_errors:
              type: object
              additionalProperties:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 500
    LocalePair:
      type: object
      additionalProperties: false
      properties:
        hu:
          type: string
          maxLength: 20000
        en:
          type: string
          maxLength: 20000
    OffsetPagination:
      type: object
      additionalProperties: false
      required: [total, limit, offset, hasMore]
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    DeviceOrderListResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: false
          required: [items, page, page_size, total]
          properties:
            items:
              type: array
              maxItems: 200
              items: { type: object, additionalProperties: true }
            page: { type: integer }
            page_size: { type: integer }
            total: { type: integer }
    DeviceOrderDetailResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: true
          properties:
            order: { type: object, additionalProperties: true }
            refunds:
              type: array
              maxItems: 100
              items: { type: object, additionalProperties: true }
            events:
              type: array
              maxItems: 1000
              items: { type: object, additionalProperties: true }
    DeviceOrderAction:
      oneOf:
        - type: object
          required: [action, note]
          additionalProperties: false
          properties:
            action: { type: string, const: append_admin_note }
            note: { type: string, maxLength: 8000, description: Admin timeline note }
        - type: object
          required: [action]
          additionalProperties: false
          properties:
            action: { type: string, const: clear_transfer_autocancel_suspension }
        - type: object
          required: [action, suspend_until]
          additionalProperties: false
          properties:
            action: { type: string, const: suspend_transfer_autocancel_until }
            suspend_until: { type: string, maxLength: 40 }
            note: { type: string, maxLength: 2000 }
        - type: object
          required: [action, payload]
          additionalProperties: false
          properties:
            action: { type: string, const: confirm_bank_transfer }
            payload:
              type: object
              additionalProperties: true
              description: amount, credited_at, bank_reference, accept_amount_mismatch, note
        - type: object
          required: [action, review_type, payload]
          additionalProperties: false
          properties:
            action: { type: string, const: apply_review_decision }
            review_type: { type: string, enum: [kyc, bank, admin] }
            payload:
              type: object
              additionalProperties: true
              description: result APPROVED or REJECTED, optional note
        - type: object
          required: [action]
          additionalProperties: false
          properties:
            action: { type: string, const: retry_automatic_fulfillment }
        - type: object
          required: [action]
          additionalProperties: false
          properties:
            action: { type: string, const: create_final_invoice }
        - type: object
          required: [action]
          additionalProperties: false
          properties:
            action: { type: string, const: create_shipment_label }
        - type: object
          required: [action, payload]
          additionalProperties: false
          properties:
            action: { type: string, const: initiate_refund }
            payload:
              type: object
              additionalProperties: true
              description: refund_type, amount, reason, optional note and release_unit_ids
        - type: object
          required: [action, refund_id, payload]
          additionalProperties: false
          properties:
            action: { type: string, const: complete_bank_transfer_refund }
            refund_id: { type: string, maxLength: 24 }
            payload:
              type: object
              additionalProperties: true
              description: manual_transfer_reference required
      discriminator:
        propertyName: action

