openapi: 3.1.0
info:
  title: Mistok (Місток) HTTP API
  version: 0.18.0
  summary: Self-hosted payments bridge for Shopify (UA + PL + international PSPs)
  description: |
    Machine-readable companion of the human docs at `/docs/api.html`.

    * **public** — buyer routes (`/pay`, `/l`, `/return`), the Order-status JSON, Nova Poshta picker, installment quote, site lead form.
    * **webhooks** — incoming callbacks of every payment provider and Shopify (raw body, signature checked per provider).
    * **admin-read** — JSON under `/admin/api/*` (dashboard stats, payments, analytics, health, audit, setup checks, webhook inspector, security, leads, me) and the HTML pages.
    * **admin-actions** — `POST` forms (`application/x-www-form-urlencoded`): sync, remind, refund retry, pay links, phone rules, QR cash registers, fiscal poll, settings, users, demo sandbox.
    * **exports** — CSV / XLSX / JSON downloads.

    Authentication for everything under `/admin` and `/status/:orderId`: `?key=ADMIN_KEY` (API scripts, acts as owner, audit login `api-key`)
    or the session cookie `mistok_admin` set by `POST /admin/login`. Roles: owner (everything), manager (`write`), viewer (`read` + export).
    Unauthenticated requests get **404** (nothing to discover), role denials **403**.

    Amounts are integers in minor units (kopecks / grosze / cents) unless stated otherwise. Dates are ISO-8601 UTC strings.

    Served by the app at `/openapi.yaml`; the Postman collection at `/postman_collection.json` is generated from this file
    (`npx openapi-to-postmanv2 -s server/openapi.yaml -o server/postman_collection.json -p -O folderStrategy=Tags` + variables `baseUrl`, `adminKey`).
    Lint: `npx @redocly/cli lint server/openapi.yaml` (config `server/redocly.yaml`).
  license:
    name: MIT
    identifier: MIT
  contact:
    name: Mistok
    url: https://mistok.cameoclub.net
externalDocs:
  description: Human-readable API docs (uk/en/ru)
  url: https://mistok.cameoclub.net/docs/api.html
servers:
  - url: "{BASE_URL}"
    description: Your Mistok instance (the BASE_URL from .env, e.g. https://pay.example.com)
    variables:
      BASE_URL:
        default: http://localhost:3000
tags:
  - name: public
    description: Buyer-facing routes and the public JSON used by the Shopify checkout extensions. No auth.
  - name: webhooks
    description: Incoming callbacks from payment providers and Shopify. Raw body, signature verified per provider. 200 = accepted, 400/401 = bad signature (not retried), 500 = retry later.
  - name: admin-read
    description: Admin JSON (`/admin/api/*`) and HTML pages. Auth adminKey or adminCookie.
  - name: admin-actions
    description: Admin POST actions (forms, urlencoded). Role write (manager+) — settings/users/demo/security/setup need owner.
  - name: exports
    description: CSV / XLSX / JSON downloads from the admin.

security: []

paths:
  # ------------------------------------------------------------------ public
  /healthz:
    get:
      tags: [public]
      operationId: healthz
      summary: Liveness probe
      description: Plain `ok`. Used by the Docker HEALTHCHECK, Caddy and deploy smoke tests.
      responses:
        "200":
          description: alive
          content:
            text/html:
              schema:
                type: string
                const: ok
        "429":
          $ref: "#/components/responses/rateLimited"
  /version:
    get:
      tags: [public]
      operationId: version
      summary: Running version
      responses:
        "200":
          description: name + version from package.json
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Version"
              example:
                name: mistok
                version: 0.18.0
        "429":
          $ref: "#/components/responses/rateLimited"
  /openapi.yaml:
    get:
      tags: [public]
      operationId: openapiSpec
      summary: This specification
      responses:
        "200":
          description: OpenAPI 3.1 document
          content:
            text/yaml:
              schema:
                type: string
        "429":
          $ref: "#/components/responses/rateLimited"
  /postman_collection.json:
    get:
      tags: [public]
      operationId: postmanCollection
      summary: Postman collection (v2.1) generated from this spec
      responses:
        "200":
          description: Postman collection with `baseUrl` and `adminKey` variables
          content:
            application/json:
              schema:
                type: object
        "429":
          $ref: "#/components/responses/rateLimited"
  /pay/{orderId}:
    get:
      tags: [public]
      operationId: pay
      summary: Start a payment for a pending Shopify order
      description: |
        Creates (or reuses) the PSP invoice and redirects (302) to the hosted payment page. The order must be `PENDING`
        or `PARTIALLY_PAID` and use a mapped manual payment method (`GATEWAY_MAP`). Prepayment rules, phone rules and failover apply.
        Rate limit `RL_PAY_PER_5MIN` (30 per 5 min per IP).
      parameters:
        - $ref: "#/components/parameters/orderId"
        - $ref: "#/components/parameters/confirmation"
        - name: p
          in: query
          description: provider key override (`mono`, `monoparts`, `privatparts`, `liqpay`, `wayforpay`, `novapay`, `p24`, `payu`, `stripe`, `tranzzo`, `rozetkapay`, `portmone`, `fondy`)
          schema:
            type: string
            pattern: "^[a-z0-9]{2,20}$"
        - name: lang
          in: query
          description: buyer language for the PSP page
          schema:
            type: string
            enum: [uk, pl, en, ru]
        - name: w
          in: query
          description: wallet hint from the Thank-you block (analytics only)
          schema:
            type: string
            enum: [wallet, apay, gpay]
      responses:
        "302":
          description: redirect to the PSP payment page, or back to the Shopify order status page when nothing is payable
          headers:
            Location:
              schema:
                type: string
                format: uri
        "403":
          description: wrong confirmation number (`REQUIRE_CONFIRMATION=true`)
        "404":
          description: unknown order
        "429":
          description: rate limited
        "502":
          description: provider or Shopify unavailable
  /l/{token}:
    get:
      tags: [public]
      operationId: payLink
      summary: Payment link for a draft order (Instagram / Telegram sales)
      description: Created in the admin (`POST /admin/links`) or by the Telegram bot `/link`. 96-bit token. Redirects to the PSP page.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]{8,64}$"
      responses:
        "302":
          description: redirect to the PSP page
        "404":
          description: unknown, used or expired link
  /return/{orderId}:
    get:
      tags: [public]
      operationId: returnFromPsp
      summary: Buyer returns from the PSP
      description: Syncs the invoice status with the provider and redirects to the Shopify Order status page. For mono installments an HTML waiting page is shown while the bank decides.
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
        - $ref: "#/components/parameters/confirmation"
        - name: lang
          in: query
          schema:
            type: string
      responses:
        "302":
          description: redirect to the Shopify order status page
        "200":
          description: waiting page (mono installments)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: unknown order
    post:
      tags: [public]
      operationId: returnFromPspPost
      summary: Same as GET (some banks POST the buyer back)
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
        - $ref: "#/components/parameters/confirmation"
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties: true
      responses:
        "302":
          description: redirect to the Shopify order status page
        "404":
          description: unknown order
  /api/order/{orderId}:
    get:
      tags: [public]
      operationId: orderStatus
      summary: Payment status JSON for the Order-status extension
      description: CORS `*`, read-only. Open invoices are re-synced with the PSP unless `sync=0`.
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
        - $ref: "#/components/parameters/confirmation"
        - name: sync
          in: query
          description: "`0` = do not call the PSP, return the stored state"
          schema:
            type: string
            enum: ["0", "1"]
      responses:
        "200":
          description: status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatus"
              example:
                orderName: "#1001"
                provider: mono
                status: paid
                prepay: false
                amount: 125000
                currency: UAH
                fiscalUrl: null
                payUrl: null
        "404":
          description: unknown order or wrong confirmation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    options:
      tags: [public]
      operationId: orderStatusPreflight
      summary: CORS preflight
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
      responses:
        "204":
          description: no content
        "429":
          $ref: "#/components/responses/rateLimited"
  /api/order/{orderId}/np:
    post:
      tags: [public]
      operationId: saveNpBranch
      summary: Save the Nova Poshta branch chosen on the Thank-you page
      description: Writes custom attributes, tag `np-branch`, a note and the `np_branch` analytics event onto the Shopify order. Needs the order confirmation code in `c`.
      parameters:
        - $ref: "#/components/parameters/orderId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NpBranchChoice"
      responses:
        "200":
          description: saved
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "400":
          description: bad input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: wrong confirmation
        "404":
          description: unknown order or NP disabled
    options:
      tags: [public]
      operationId: saveNpBranchPreflight
      summary: CORS preflight
      parameters:
        - $ref: "#/components/parameters/orderId"
      responses:
        "204":
          description: no content
        "429":
          $ref: "#/components/responses/rateLimited"
  /api/np/cities:
    get:
      tags: [public]
      operationId: npCities
      summary: Nova Poshta settlement search
      description: "`Address.searchSettlements`, cached 10 min. 404 when `NP_API_KEY` is not set. Limit `RL_NP_PER_MIN` (60/min per IP)."
      parameters:
        - name: q
          in: query
          required: true
          description: 2–60 characters
          schema:
            type: string
            minLength: 2
            maxLength: 60
      responses:
        "200":
          description: matches
          content:
            application/json:
              schema:
                type: object
                properties:
                  cities:
                    type: array
                    items:
                      $ref: "#/components/schemas/NpCity"
        "404":
          description: np_disabled
  /api/np/warehouses:
    get:
      tags: [public]
      operationId: npWarehouses
      summary: Nova Poshta branches and parcel lockers of a settlement
      parameters:
        - name: city
          in: query
          required: true
          description: settlement ref from `/api/np/cities`
          schema:
            type: string
            pattern: "^[\\w-]{2,64}$"
        - name: q
          in: query
          description: filter by number or street
          schema:
            type: string
            maxLength: 40
      responses:
        "200":
          description: warehouses
          content:
            application/json:
              schema:
                type: object
                properties:
                  warehouses:
                    type: array
                    items:
                      $ref: "#/components/schemas/NpWarehouse"
        "400":
          description: bad_city
        "404":
          description: np_disabled
  /api/installments/quote:
    get:
      tags: [public]
      operationId: installmentsQuote
      summary: Installment offers for the product-page badge
      description: CORS `*`, `Cache-Control public, max-age=300`, limit `RL_QUOTE_PER_MIN` (240/min per IP). Offers only from configured providers within bank limits.
      parameters:
        - name: amount
          in: query
          required: true
          description: price in minor units (kopecks)
          schema:
            type: integer
            minimum: 1
            maximum: 1000000000
        - name: currency
          in: query
          schema:
            type: string
            default: UAH
            pattern: "^[A-Za-z]{3}$"
      responses:
        "200":
          description: quote
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InstallmentQuote"
        "400":
          description: bad_amount
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    options:
      tags: [public]
      operationId: installmentsQuotePreflight
      summary: CORS preflight
      responses:
        "204":
          description: no content
        "429":
          $ref: "#/components/responses/rateLimited"
  /api/lead:
    post:
      tags: [public]
      operationId: siteLead
      summary: Site request form (pricing / request pages)
      description: Stored in `leads` and sent to the owner's Telegram. Honeypot field `website` — when filled the request is silently accepted. 5 per IP per 10 min.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadInput"
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LeadInput"
      responses:
        "200":
          description: accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  id:
                    type: integer
        "400":
          description: contact_required | bad_email
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  error:
                    type: string
    options:
      tags: [public]
      operationId: siteLeadPreflight
      summary: CORS preflight
      responses:
        "204":
          description: no content
        "429":
          $ref: "#/components/responses/rateLimited"
  /status/{orderId}:
    get:
      tags: [admin-read]
      operationId: rawRecord
      summary: Raw stored payment record (scripts)
      description: Key only (`?key=ADMIN_KEY`); 404 for anything else.
      security:
        - adminKey: []
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
      responses:
        "200":
          description: record or `null`
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PaymentRecord"
                  - type: "null"
        "404":
          description: bad key or id

  # ------------------------------------------------------------------ webhooks
  /webhooks/mono:
    post:
      tags: [webhooks]
      operationId: webhookMono
      summary: plata by mono invoice status
      description: ECDSA signature in `X-Sign` verified against the public key from the mono API (re-fetched once on mismatch).
      parameters:
        - name: X-Sign
          in: header
          required: true
          schema:
            type: string
            maxLength: 512
      requestBody:
        $ref: "#/components/requestBodies/monoWebhook"
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/mono/{secret}:
    post:
      tags: [webhooks]
      operationId: webhookMonoSecret
      summary: plata by mono (with MONO_WEBHOOK_SECRET in the path)
      parameters:
        - name: secret
          in: path
          required: true
          schema:
            type: string
        - name: X-Sign
          in: header
          required: true
          schema:
            type: string
      requestBody:
        $ref: "#/components/requestBodies/monoWebhook"
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "404":
          description: wrong secret
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/monoparts:
    post:
      tags: [webhooks]
      operationId: webhookMonoparts
      summary: mono «Покупка частинами» order state
      description: HMAC-SHA256 (base64, 44 chars) of the raw body with `MONOPARTS_SECRET_KEY` in the `signature` header.
      parameters:
        - name: signature
          in: header
          required: true
          schema:
            type: string
            minLength: 44
            maxLength: 44
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                order_id:
                  type: string
                state:
                  type: string
                  enum: [SUCCESS, FAIL, REJECTED, IN_PROCESS, CREATED]
                order_sub_state:
                  type: string
                total_sum:
                  type: number
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/privatparts:
    post:
      tags: [webhooks]
      operationId: webhookPrivatparts
      summary: PrivatBank «Оплата частинами» (PayParts) callback
      description: "`signature` inside the body = base64(SHA1(password + storeId + orderId + paymentState + message + password))."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PrivatpartsCallback"
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/PrivatpartsCallback"
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/liqpay:
    post:
      tags: [webhooks]
      operationId: webhookLiqpay
      summary: LiqPay server callback
      description: Form fields `data` (base64 JSON) + `signature` = base64(SHA1(private_key + data + private_key)); JSON body with the same two fields is accepted too.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/DataSignature"
          application/json:
            schema:
              $ref: "#/components/schemas/DataSignature"
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/wayforpay:
    post:
      tags: [webhooks]
      operationId: webhookWayforpay
      summary: WayForPay serviceUrl callback
      description: JSON (or the JSON document as the sole key of a form body) with `merchantSignature` = HMAC-MD5. Reply is the signed `{orderReference, status accept, time, signature}` JSON.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orderReference, merchantSignature]
              properties:
                orderReference:
                  type: string
                transactionStatus:
                  type: string
                merchantSignature:
                  type: string
                  maxLength: 64
              additionalProperties: true
      responses:
        "200":
          description: signed accept reply
          content:
            application/json:
              schema:
                type: object
                properties:
                  orderReference:
                    type: string
                  status:
                    type: string
                    const: accept
                  time:
                    type: integer
                  signature:
                    type: string
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/novapay:
    post:
      tags: [webhooks]
      operationId: webhookNovapay
      summary: NovaPay postback
      description: RSA signature of the raw body in `x-sign-v2`, verified with `NOVAPAY_PUBLIC_KEY`.
      parameters:
        - name: x-sign-v2
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/p24:
    post:
      tags: [webhooks]
      operationId: webhookP24
      summary: Przelewy24 transaction notification
      description: JSON with `sign` = SHA-384 of the notification fields + CRC (96 hex chars). The transaction is then verified with `PUT /api/v1/transaction/verify`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, sign]
              properties:
                merchantId:
                  type: integer
                posId:
                  type: integer
                sessionId:
                  type: string
                amount:
                  type: integer
                originAmount:
                  type: integer
                currency:
                  type: string
                orderId:
                  type: integer
                methodId:
                  type: integer
                statement:
                  type: string
                sign:
                  type: string
                  minLength: 96
                  maxLength: 96
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/payu:
    post:
      tags: [webhooks]
      operationId: webhookPayu
      summary: PayU order notification
      description: "`OpenPayu-Signature: sender=…;signature=<hex>;algorithm=MD5|SHA-256` over body + second key."
      parameters:
        - name: OpenPayu-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                order:
                  type: object
                  additionalProperties: true
                refund:
                  type: object
                  additionalProperties: true
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/stripe:
    post:
      tags: [webhooks]
      operationId: webhookStripe
      summary: Stripe event
      description: "`Stripe-Signature: t=…,v1=<hex HMAC-SHA256 of t.body with STRIPE_WEBHOOK_SECRET>`; events checkout.session.*, charge.refunded."
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id:
                  type: string
                type:
                  type: string
                data:
                  type: object
                  additionalProperties: true
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/tranzzo:
    post:
      tags: [webhooks]
      operationId: webhookTranzzo
      summary: Tranzzo callback
      description: "`data` (base64 JSON) + `signature` (base64 SHA1 of secret+data+secret), JSON or form body."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DataSignature"
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/DataSignature"
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/rozetkapay:
    post:
      tags: [webhooks]
      operationId: webhookRozetkapay
      summary: RozetkaPay callback
      description: "`X-Rozetkapay-Signature` = base64 HMAC-SHA256 of the raw body with the merchant password."
      parameters:
        - name: X-Rozetkapay-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/portmone:
    post:
      tags: [webhooks]
      operationId: webhookPortmone
      summary: Portmone callback
      description: JSON or form body signed with the Portmone key; reply `{errorCode "0"}` for JSON callers or `OK`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/fondy:
    post:
      tags: [webhooks]
      operationId: webhookFondy
      summary: Fondy / Flitt callback
      description: JSON (`response` object or flat) with `signature` = SHA1 of the sorted non-empty fields joined with `|` and the secret key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                order_id:
                  type: string
                order_status:
                  type: string
                signature:
                  type: string
                response:
                  type: object
                  additionalProperties: true
              additionalProperties: true
      responses:
        "200":
          $ref: "#/components/responses/webhookOk"
        "400":
          $ref: "#/components/responses/webhookBadSignature"
        "500":
          $ref: "#/components/responses/webhookRetry"
  /webhooks/shopify:
    post:
      tags: [webhooks]
      operationId: webhookShopify
      summary: Shopify order webhooks
      description: HMAC-SHA256 (base64) of the raw body with `SHOPIFY_API_SECRET` in `X-Shopify-Hmac-Sha256`; deduplicated by `X-Shopify-Webhook-Id`. Topics orders/create, refunds/create, orders/cancelled, orders/edited, orders/fulfilled, orders/paid.
      parameters:
        - name: X-Shopify-Hmac-Sha256
          in: header
          required: true
          schema:
            type: string
        - name: X-Shopify-Topic
          in: header
          required: true
          schema:
            type: string
            enum: [orders/create, refunds/create, orders/cancelled, orders/edited, orders/fulfilled, orders/paid]
        - name: X-Shopify-Webhook-Id
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Shopify REST webhook payload (order, refund …)
              additionalProperties: true
      responses:
        "200":
          description: ok | dup (already processed) | noted (permanent error, do not retry)
          content:
            text/html:
              schema:
                type: string
        "400":
          description: bad json
        "401":
          description: bad hmac
        "500":
          description: retry

  # ------------------------------------------------------------------ admin: auth
  /admin/login:
    get:
      tags: [admin-read]
      operationId: adminLoginPage
      summary: Login page (404 when ADMIN_PASSWORD is empty and no users exist)
      responses:
        "200":
          description: HTML form
          content:
            text/html:
              schema:
                type: string
        "302":
          description: already logged in → /admin/dashboard
        "404":
          description: login disabled (key-only setup)
    post:
      tags: [admin-actions]
      operationId: adminLogin
      summary: Log in (password, optional Telegram 2FA code) → cookie mistok_admin
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                login:
                  type: string
                  description: user login; empty / `owner` = the .env ADMIN_PASSWORD account
                password:
                  type: string
                code:
                  type: string
                  description: 6-digit Telegram code (second step when ADMIN_2FA=telegram)
                challenge:
                  type: string
                  description: challenge token returned by the first step
      responses:
        "302":
          description: logged in → /admin/dashboard (Set-Cookie mistok_admin)
          headers:
            Set-Cookie:
              schema:
                type: string
        "200":
          description: second step (2FA code form)
          content:
            text/html:
              schema:
                type: string
        "401":
          description: wrong password / code (5 failures → 15 min lockout per IP)
  /admin/logout:
    get:
      tags: [admin-actions]
      operationId: adminLogout
      summary: Log out (revokes the session)
      security:
        - adminCookie: []
        - adminKey: []
      responses:
        "302":
          description: → /admin/login or BASE_URL
        "404":
          $ref: "#/components/responses/unauth"
  /admin/lang/{lang}:
    get:
      tags: [admin-actions]
      operationId: adminLang
      summary: Switch admin UI language (cookie mistok_lang)
      parameters:
        - name: lang
          in: path
          required: true
          schema:
            type: string
            enum: [uk, ua, ru]
        - name: back
          in: query
          schema:
            type: string
      responses:
        "302":
          description: back to `back` (must start with /admin) or the dashboard
        "429":
          $ref: "#/components/responses/rateLimited"

  # ------------------------------------------------------------------ admin: pages
  /admin:
    get:
      tags: [admin-read]
      operationId: adminRoot
      summary: Redirect to the dashboard
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "302":
          description: → /admin/dashboard
        "404":
          description: not authenticated
  /admin/dashboard:
    get:
      tags: [admin-read]
      operationId: adminDashboard
      summary: Dashboard (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/days"
        - name: selftest
          in: query
          description: show the self-test report of this order id
          schema:
            type: string
        - name: reconcile
          in: query
          description: "`1` = show the last reconciliation report"
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/analytics:
    get:
      tags: [admin-read]
      operationId: adminAnalyticsPage
      summary: Analytics module (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/days"
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/payments:
    get:
      tags: [admin-read]
      operationId: adminPaymentsPage
      summary: Payments list (HTML, 50 per page)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/status"
        - $ref: "#/components/parameters/paid"
        - $ref: "#/components/parameters/provider"
        - $ref: "#/components/parameters/country"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
        - $ref: "#/components/parameters/kind"
        - $ref: "#/components/parameters/page"
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/order/{orderId}:
    get:
      tags: [admin-read]
      operationId: adminOrderPage
      summary: Order card (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          description: no payment for this order (HTML) or not authenticated
  /admin/links:
    get:
      tags: [admin-read]
      operationId: adminLinksPage
      summary: Pay links (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
    post:
      tags: [admin-actions]
      operationId: adminLinkCreate
      summary: Create a payment link (draft order)
      description: "Either `draftId` of an existing Shopify draft, or `title` + `amount` (major units, e.g. `1250.50`) to create one. With `Accept: application/json` the link is returned as JSON, otherwise redirect to /admin/links."
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LinkInput"
          application/json:
            schema:
              $ref: "#/components/schemas/LinkInput"
      responses:
        "200":
          description: created (Accept json)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinkCreated"
        "302":
          description: → /admin/links
        "400":
          description: Shopify / provider error text
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/phones:
    get:
      tags: [admin-read]
      operationId: adminPhonesPage
      summary: Phone rules (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: phone
          in: query
          description: prefill the form
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/phone-rules:
    post:
      tags: [admin-actions]
      operationId: adminPhoneRule
      summary: Add / change / delete a phone rule
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [phone, mode]
              properties:
                phone:
                  type: string
                  description: any format, normalised to E.164
                mode:
                  type: string
                  enum: [prepay, block, trusted, delete]
                note:
                  type: string
                  maxLength: 200
      responses:
        "302":
          description: → /admin/phones
        "400":
          description: bad phone | bad mode
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/events:
    get:
      tags: [admin-read]
      operationId: adminEventsPage
      summary: Outgoing event deliveries (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/events/{id}/redeliver:
    post:
      tags: [admin-actions]
      operationId: adminEventRedeliver
      summary: Re-send an outgoing event delivery
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: id
          in: path
          required: true
          description: delivery uuid
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/back"
      responses:
        "302":
          description: → /admin/events (or the order card)
        "404":
          description: unknown delivery
  /admin/qr:
    get:
      tags: [admin-read]
      operationId: adminQrPage
      summary: mono QR cash registers (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/qr/{qrId}/show:
    post:
      tags: [admin-actions]
      operationId: adminQrShow
      summary: Put an order's amount on a physical QR cash register
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/qrId"
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [orderId]
              properties:
                orderId:
                  type: string
                  pattern: "^\\d{1,20}$"
                qrId:
                  type: string
                  description: overrides the path id (select in the order card)
                back:
                  type: string
                  enum: [order]
      responses:
        "200":
          description: the invoice record (Accept json)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentRecord"
        "302":
          description: → /admin/qr or the order card
        "400":
          description: bad qrId or orderId
        "502":
          description: mono error
  /admin/qr/{qrId}/clear:
    post:
      tags: [admin-actions]
      operationId: adminQrClear
      summary: Remove the amount from a QR cash register
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/qrId"
      responses:
        "200":
          description: ok (Accept json)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ok"
        "302":
          description: → /admin/qr
        "400":
          description: bad qrId
        "502":
          description: mono error
  /admin/settings:
    get:
      tags: [admin-read]
      operationId: adminSettingsPage
      summary: Settings overview + overrides form (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: saved
          in: query
          schema:
            type: string
        - name: imported
          in: query
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
    post:
      tags: [admin-actions]
      operationId: adminSettingsSave
      summary: Save admin overrides of .env keys (owner)
      description: Every submitted `CONFIG_KEY` is validated (JSON for GATEWAY_MAP / PROVIDER_FALLBACK / PREPAY_RULES, numbers, regex, provider names). `reset=<KEY>` drops one override. Values equal to .env are not stored.
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                reset:
                  type: string
                  description: key to reset to .env
                GATEWAY_MAP:
                  type: string
                PROVIDER_FALLBACK:
                  type: string
                PREPAY_RULES:
                  type: string
                REMINDER_AFTER_HOURS:
                  type: string
                REMINDER_GAP_HOURS:
                  type: string
                REMINDER_MAX:
                  type: string
                COD_GATEWAY_REGEX:
                  type: string
                LINK_PROVIDER:
                  type: string
                LINK_CURRENCY:
                  type: string
              additionalProperties:
                type: string
      responses:
        "302":
          description: → /admin/settings?saved=1
        "400":
          description: validation error (settings page with the message)
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/settings/export.json:
    get:
      tags: [exports]
      operationId: adminSettingsExport
      summary: Export overrides + phone rules as JSON (owner; secrets never included)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: attachment mistok-settings-<shop>-<date>.json
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsExport"
        "403":
          $ref: "#/components/responses/forbiddenJson"
  /admin/settings/import:
    post:
      tags: [admin-actions]
      operationId: adminSettingsImport
      summary: Import an export file or a bare {KEY value} patch (owner)
      description: Unknown keys reject the whole import; nothing is applied on error.
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [json]
              properties:
                json:
                  type: string
                  description: the JSON document (textarea)
      responses:
        "302":
          description: → /admin/settings?saved=1&imported=<keys>+<rules>
        "400":
          description: invalid JSON / unknown keys
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/users:
    get:
      tags: [admin-read]
      operationId: adminUsersPage
      summary: Users and roles (HTML, owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "403":
          $ref: "#/components/responses/forbidden"
    post:
      tags: [admin-actions]
      operationId: adminUserCreate
      summary: Create a user (owner)
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [login, password, role]
              properties:
                login:
                  type: string
                  pattern: "^[a-z0-9._-]{2,32}$"
                name:
                  type: string
                  maxLength: 80
                role:
                  $ref: "#/components/schemas/Role"
                password:
                  type: string
                  description: min 10 characters, letters and digits
                tg_chat_id:
                  type: string
                  description: Telegram chat id for 2FA codes
      responses:
        "302":
          description: → /admin/users?saved=<id>
        "400":
          description: validation error
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/users/{id}:
    post:
      tags: [admin-actions]
      operationId: adminUserUpdate
      summary: Update a user (owner) — name, role, password, Telegram id, active flag
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                name:
                  type: string
                role:
                  $ref: "#/components/schemas/Role"
                password:
                  type: string
                tg_chat_id:
                  type: string
                active:
                  type: string
                  enum: ["0", "1"]
      responses:
        "302":
          description: → /admin/users?saved=<id>
        "400":
          description: validation error (e.g. last active owner)
        "403":
          $ref: "#/components/responses/forbidden"
        "404":
          description: unknown user
  /admin/leads:
    get:
      tags: [admin-read]
      operationId: adminLeadsPage
      summary: Site requests (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/audit:
    get:
      tags: [admin-read]
      operationId: adminAuditPage
      summary: Audit log (HTML, owner + manager)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/auditLogin"
        - $ref: "#/components/parameters/auditAction"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
        - $ref: "#/components/parameters/page"
      responses:
        "200":
          $ref: "#/components/responses/html"
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/health:
    get:
      tags: [admin-read]
      operationId: adminHealthPage
      summary: Health page — process, DB, disk, backups, webhooks, deliveries, fiscal, per-PSP 24 h, recent errors (HTML); `?deep=1` adds live PSP probes with latency (manager/owner)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: deep
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/health/deep:
    post:
      tags: [admin-actions]
      operationId: adminHealthDeep
      summary: Run the live PSP probes and show the health page with latencies (redirects to /admin/health?deep=1)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "302":
          description: redirect to /admin/health?deep=1
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/setup:
    get:
      tags: [admin-read]
      operationId: adminSetupPage
      summary: Setup wizard — last check results (HTML, owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          $ref: "#/components/responses/html"
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/setup/run:
    post:
      tags: [admin-actions]
      operationId: adminSetupRun
      summary: Run the live read-only integration checks (owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: report (Accept json)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SetupReport"
        "302":
          description: → /admin/setup
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/webhooks:
    get:
      tags: [admin-read]
      operationId: adminWebhooksPage
      summary: Incoming webhook inspector (HTML)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/whSource"
        - $ref: "#/components/parameters/whOk"
        - $ref: "#/components/parameters/page"
      responses:
        "200":
          $ref: "#/components/responses/html"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/webhooks/{id}/replay:
    post:
      tags: [admin-actions]
      operationId: adminWebhookReplay
      summary: Replay a logged webhook (re-syncs the matched order with the PSP)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: synced (Accept json)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  status:
                    type: string
        "302":
          description: → /admin/webhooks
        "404":
          description: no such log row / no matched order
        "502":
          description: provider error
  /admin/security:
    get:
      tags: [admin-read]
      operationId: adminSecurityPage
      summary: Security panel — failed logins, locked IPs, sessions (HTML, owner)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: done
          in: query
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/html"
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/security/unlock:
    post:
      tags: [admin-actions]
      operationId: adminSecurityUnlock
      summary: Unlock an IP after failed logins (owner)
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [ip]
              properties:
                ip:
                  type: string
      responses:
        "302":
          description: → /admin/security?done=…
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/security/sessions/revoke:
    post:
      tags: [admin-actions]
      operationId: adminSessionRevoke
      summary: Revoke one admin session (owner)
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [nonce]
              properties:
                nonce:
                  type: string
      responses:
        "302":
          description: → /admin/security
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/security/sessions/revoke-all:
    post:
      tags: [admin-actions]
      operationId: adminSessionRevokeAll
      summary: Revoke every admin session except the current one (owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "302":
          description: → /admin/security
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/demo/on:
    post:
      tags: [admin-actions]
      operationId: adminDemoOn
      summary: Switch the demo sandbox on (seeds fictional payments into an empty DB; owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "302":
          description: → /admin/settings
        "400":
          description: DEMO_DATA=false
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/demo/off:
    post:
      tags: [admin-actions]
      operationId: adminDemoOff
      summary: Switch the demo sandbox off and purge every demo row (owner)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "302":
          description: → /admin/settings
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/selftest:
    post:
      tags: [admin-actions]
      operationId: adminSelftest
      summary: Test purchase — a fictional order walked through every stage inside Mistok (no PSP / Shopify calls)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: report (Accept json); 500 when a step failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SelftestReport"
        "302":
          description: → /admin/dashboard?selftest=<orderId>
        "403":
          $ref: "#/components/responses/forbidden"
        "500":
          description: a step failed (JSON report)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SelftestReport"
  /admin/reconcile/run:
    post:
      tags: [admin-actions]
      operationId: adminReconcileRun
      summary: Reconcile Shopify ↔ Mistok ↔ PSP for the last RECONCILE_DAYS
      security:
        - adminKey: []
        - adminCookie: []
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                notify:
                  type: string
                  description: "`1` = also send the report to Telegram"
                  enum: ["1"]
                back:
                  type: string
                  enum: [dashboard]
      responses:
        "200":
          description: report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReconcileReport"
        "302":
          description: → /admin/dashboard?reconcile=1 (back=dashboard)
        "404":
          $ref: "#/components/responses/unauth"
        "500":
          description: error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /admin/sync/{orderId}:
    post:
      tags: [admin-actions]
      operationId: adminSync
      summary: Re-read the invoice status from the PSP and update Shopify
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
        - $ref: "#/components/parameters/back"
      requestBody:
        $ref: "#/components/requestBodies/backForm"
      responses:
        "302":
          description: → /admin/payments or the order card
        "404":
          description: unknown order
        "502":
          description: provider error text
  /admin/remind/{orderId}:
    post:
      tags: [admin-actions]
      operationId: adminRemind
      summary: Send the unpaid-order reminder now (email + SMS)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
      responses:
        "302":
          description: → order card
        "404":
          description: unknown order
        "502":
          description: delivery error text
  /admin/refund-retry/{refundId}:
    post:
      tags: [admin-actions]
      operationId: adminRefundRetry
      summary: Retry a failed PSP refund for a Shopify refund
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: refundId
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/back"
      requestBody:
        $ref: "#/components/requestBodies/backForm"
      responses:
        "302":
          description: → /admin/payments or the order card
        "404":
          description: unknown refund
        "502":
          description: provider error text
  /admin/fiscal/{orderId}.pdf:
    get:
      tags: [exports]
      operationId: adminFiscalPdf
      summary: Fiscal receipt PDF (mono PRRO)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderId"
        - name: type
          in: query
          schema:
            type: string
            enum: [sale, return]
            default: sale
      responses:
        "200":
          description: PDF
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "404":
          description: no receipt yet / provider has no PDF
        "502":
          description: provider error
  /admin/fiscal/{orderId}/poll:
    post:
      tags: [admin-actions]
      operationId: adminFiscalPoll
      summary: Check the fiscal receipt status now
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderId"
      requestBody:
        $ref: "#/components/requestBodies/backForm"
      responses:
        "200":
          description: receipt row or null
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FiscalReceipt"
                  - type: "null"
        "302":
          description: → order card (back=order)
        "404":
          description: unknown order
        "502":
          description: error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /admin/reminders/run:
    post:
      tags: [admin-actions]
      operationId: adminRemindersRun
      summary: Run the reminder scheduler once
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: how many reminders were sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent:
                    type: integer
              example:
                sent: 0
        "404":
          $ref: "#/components/responses/unauth"
        "502":
          description: error text

  # ------------------------------------------------------------------ admin: JSON API
  /admin/api/me:
    get:
      tags: [admin-read]
      operationId: adminMe
      summary: Who am I (login, role, auth mode)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: identity
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Me"
              example:
                login: api-key
                name: API key
                role: owner
                mode: key
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/health:
    get:
      tags: [admin-read]
      operationId: adminHealth
      summary: Health for monitoring — DB, providers, queues, last 24 h counters; `?full=1` returns the full snapshot (status ok|warn|crit, issues[], process, db, disk, backup, webhooks, deliveries, fiscal, refunds, payments, psp[], schedulers, errors[]), `?deep=1` adds live PSP probes (deep[] with ms)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: full
          in: query
          schema: { type: string, enum: ["1"] }
        - name: deep
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: "degraded (DB error) — same schema with `status: degraded`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/stats:
    get:
      tags: [admin-read]
      operationId: adminStats
      summary: Dashboard statistics (funnel, geography, providers, devices, drop-offs, anomalies)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: days
          in: query
          description: 1–365 (default 30)
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
      responses:
        "200":
          description: stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Stats"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/analytics:
    get:
      tags: [admin-read]
      operationId: adminAnalytics
      summary: Deep analytics (sources, campaigns, buyers, products, time to pay, reminders, fees, cohorts)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/days"
      responses:
        "200":
          description: analytics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Analytics"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/payments:
    get:
      tags: [admin-read]
      operationId: adminPayments
      summary: Payment records — latest 500, or a filtered search
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/status"
        - $ref: "#/components/parameters/paid"
        - $ref: "#/components/parameters/provider"
        - $ref: "#/components/parameters/country"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
        - $ref: "#/components/parameters/kind"
      responses:
        "200":
          description: records (newest first)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/PaymentRecord"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/events:
    get:
      tags: [admin-read]
      operationId: adminDeliveries
      summary: Outgoing event deliveries (CRM webhook), latest 500
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: deliveries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Delivery"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/events/{orderId}:
    get:
      tags: [admin-read]
      operationId: adminOrderEvents
      summary: Funnel events of one order (order_created → pay_click → redirected → returned → paid …)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/orderIdOrDraft"
      responses:
        "200":
          description: events (empty array for unknown orders)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/FunnelEvent"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/qr:
    get:
      tags: [admin-read]
      operationId: adminQr
      summary: mono QR cash registers with their current amount / order
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: registers (empty when mono is not configured)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/QrRegister"
        "502":
          description: mono API error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/audit:
    get:
      tags: [admin-read]
      operationId: adminAudit
      summary: Audit log rows (owner + manager)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/auditLogin"
        - $ref: "#/components/parameters/auditAction"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: rows
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditList"
        "403":
          $ref: "#/components/responses/forbiddenJson"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/setup:
    get:
      tags: [admin-read]
      operationId: adminSetup
      summary: Setup wizard results (owner); `run=1` executes the checks first
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: run
          in: query
          schema:
            type: string
            enum: ["1"]
      responses:
        "200":
          description: report (`{results [], summary null}` before the first run)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SetupReport"
        "403":
          $ref: "#/components/responses/forbiddenJson"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/webhooks:
    get:
      tags: [admin-read]
      operationId: adminWebhookLog
      summary: Incoming webhook inspector rows
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/whSource"
        - $ref: "#/components/parameters/whOk"
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: rows
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookLogList"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/security:
    get:
      tags: [admin-read]
      operationId: adminSecurity
      summary: Failed logins (7 days), locked IPs, active sessions (owner; nonces never exposed)
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: security state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Security"
        "403":
          $ref: "#/components/responses/forbiddenJson"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/leads:
    get:
      tags: [admin-read]
      operationId: adminLeads
      summary: Site requests (leads)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/limit"
      responses:
        "200":
          description: leads, newest first
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Lead"
        "404":
          $ref: "#/components/responses/unauth"
  /admin/api/selftest/last:
    get:
      tags: [admin-read]
      operationId: adminSelftestLast
      summary: Report of the last test purchase
      security:
        - adminKey: []
        - adminCookie: []
      responses:
        "200":
          description: report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SelftestReport"
        "404":
          description: not_run (or not authenticated)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ------------------------------------------------------------------ exports
  /admin/payments.csv:
    get:
      tags: [exports]
      operationId: adminPaymentsCsv
      summary: Payments CSV (`;` separated, UTF-8 BOM, up to 1000 rows, same filters as the list)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/status"
        - $ref: "#/components/parameters/paid"
        - $ref: "#/components/parameters/provider"
        - $ref: "#/components/parameters/country"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
        - $ref: "#/components/parameters/kind"
      responses:
        "200":
          description: attachment mistok-payments-<date>.csv
          content:
            text/csv:
              schema:
                type: string
        "404":
          $ref: "#/components/responses/unauth"
  /admin/analytics.csv:
    get:
      tags: [exports]
      operationId: adminAnalyticsCsv
      summary: Analytics CSV (sections source / campaign / product / weekday / country_provider / discount / fee / cohort)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/days"
      responses:
        "200":
          description: attachment mistok-analytics-<days>d.csv
          content:
            text/csv:
              schema:
                type: string
        "404":
          $ref: "#/components/responses/unauth"
  /admin/audit.csv:
    get:
      tags: [exports]
      operationId: adminAuditCsv
      summary: Audit log CSV (up to 1000 rows)
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - $ref: "#/components/parameters/q"
        - $ref: "#/components/parameters/auditLogin"
        - $ref: "#/components/parameters/auditAction"
        - $ref: "#/components/parameters/from"
        - $ref: "#/components/parameters/to"
      responses:
        "200":
          description: attachment mistok-audit-<date>.csv
          content:
            text/csv:
              schema:
                type: string
        "403":
          $ref: "#/components/responses/forbidden"
  /admin/export/accounting.xlsx:
    get:
      tags: [exports]
      operationId: adminAccountingXlsx
      summary: Monthly accounting workbook — payments, refunds, fiscal receipts, per-provider totals
      security:
        - adminKey: []
        - adminCookie: []
      parameters:
        - name: month
          in: query
          description: YYYY-MM, default previous month
          schema:
            type: string
            pattern: "^\\d{4}-\\d{2}$"
      responses:
        "200":
          description: attachment mistok-accounting-<month>.xlsx
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
        "403":
          $ref: "#/components/responses/forbidden"

components:
  securitySchemes:
    adminKey:
      type: apiKey
      in: query
      name: key
      description: "`ADMIN_KEY` from .env — scripts and API clients; acts as owner (audit login `api-key`)"
    adminCookie:
      type: apiKey
      in: cookie
      name: mistok_admin
      description: session cookie set by `POST /admin/login` (HttpOnly, SameSite=Lax, ADMIN_SESSION_HOURS)

  parameters:
    orderId:
      name: orderId
      in: path
      required: true
      description: numeric Shopify order id
      schema:
        type: string
        pattern: "^\\d{1,20}$"
    orderIdOrDraft:
      name: orderId
      in: path
      required: true
      description: numeric Shopify order id, or `draft-<id>` for pay links
      schema:
        type: string
        pattern: "^(draft-)?\\d{1,20}$"
    confirmation:
      name: c
      in: query
      description: order confirmation number (required when `REQUIRE_CONFIRMATION=true`); for draft returns — the link token
      schema:
        type: string
    days:
      name: days
      in: query
      description: period
      schema:
        type: integer
        enum: [7, 30, 90]
        default: 30
    page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    limit:
      name: limit
      in: query
      description: max rows (default 200)
      schema:
        type: integer
        minimum: 1
    q:
      name: q
      in: query
      description: free text (order name, phone, email, invoice id …)
      schema:
        type: string
        maxLength: 80
    status:
      name: status
      in: query
      description: PSP status
      schema:
        type: string
        enum: [created, processing, hold, success, failure, reversed, expired]
    paid:
      name: paid
      in: query
      description: "`1` = marked paid in Shopify, `0` = not"
      schema:
        type: string
        enum: ["0", "1"]
    provider:
      name: provider
      in: query
      schema:
        type: string
    country:
      name: country
      in: query
      description: ISO-3166 alpha-2
      schema:
        type: string
    from:
      name: from
      in: query
      description: YYYY-MM-DD
      schema:
        type: string
        format: date
    to:
      name: to
      in: query
      description: YYYY-MM-DD
      schema:
        type: string
        format: date
    kind:
      name: kind
      in: query
      schema:
        type: string
        enum: [order, draft]
    back:
      name: back
      in: query
      description: "`order:<id>` = redirect to that order card after the action"
      schema:
        type: string
    qrId:
      name: qrId
      in: path
      required: true
      schema:
        type: string
        pattern: "^[A-Za-z0-9_-]{4,40}$"
    auditLogin:
      name: login
      in: query
      schema:
        type: string
    auditAction:
      name: action
      in: query
      description: e.g. login, logout, login_failed, denied, sync, remind, settings, settings.import, users, links, qr.show
      schema:
        type: string
    whSource:
      name: source
      in: query
      description: provider key or `shopify`
      schema:
        type: string
    whOk:
      name: ok
      in: query
      description: "`1` = verified, `0` = rejected"
      schema:
        type: string
        enum: ["0", "1"]

  requestBodies:
    monoWebhook:
      required: true
      content:
        application/json:
          schema:
            type: object
            description: mono invoice status object (same shape as GET /api/merchant/invoice/status)
            properties:
              invoiceId:
                type: string
              status:
                type: string
                enum: [created, processing, hold, success, failure, reversed, expired]
              amount:
                type: integer
              ccy:
                type: integer
              reference:
                type: string
              failureReason:
                type: string
              modifiedDate:
                type: string
            additionalProperties: true
    backForm:
      required: false
      content:
        application/x-www-form-urlencoded:
          schema:
            type: object
            properties:
              back:
                type: string
                enum: [order]
                description: redirect to the order card instead of the list

  responses:
    html:
      description: HTML page
      content:
        text/html:
          schema:
            type: string
    unauth:
      description: not authenticated — 404 on purpose (nothing to discover); browsers are redirected to /admin/login when a password is configured
    forbidden:
      description: role does not allow this (403)
    forbiddenJson:
      description: role does not allow this
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    rateLimited:
      description: rate limited (RL_GLOBAL_PER_MIN, draft-8 RateLimit headers)
    webhookOk:
      description: accepted (`ok`, or the provider-specific acknowledgement)
      content:
        text/html:
          schema:
            type: string
    webhookBadSignature:
      description: bad signature / body — the provider must not retry
      content:
        text/html:
          schema:
            type: string
    webhookRetry:
      description: temporary sync error — the provider should retry
      content:
        text/html:
          schema:
            type: string

  schemas:
    Version:
      type: object
      required: [name, version]
      properties:
        name:
          type: string
          const: mistok
        version:
          type: string
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
    Ok:
      type: object
      properties:
        ok:
          type: boolean
    Role:
      type: string
      enum: [owner, manager, viewer]
    OrderStatus:
      type: object
      required: [orderName, provider, status, prepay, amount, currency]
      properties:
        orderName:
          type: string
          examples: ["#1001"]
        provider:
          type: string
        status:
          type: string
          description: "`paid` | `pending` | `failed` | reversed | …"
        prepay:
          type: boolean
          description: partial (prepayment) invoice
        amount:
          type: integer
        currency:
          type: string
        fiscalUrl:
          type: [string, "null"]
          description: fiscal receipt URL once issued
        payUrl:
          type: [string, "null"]
          description: fresh pay link while unpaid
    NpBranchChoice:
      type: object
      required: [c, cityRef, cityName, warehouseRef, warehouseName]
      properties:
        c:
          type: string
          description: order confirmation code
        cityRef:
          type: string
        cityName:
          type: string
        warehouseRef:
          type: string
        warehouseName:
          type: string
        type:
          type: string
          enum: [branch, postomat]
    NpCity:
      type: object
      properties:
        ref:
          type: string
        name:
          type: string
        area:
          type: string
      additionalProperties: true
    NpWarehouse:
      type: object
      properties:
        ref:
          type: string
        name:
          type: string
        number:
          type: string
        type:
          type: string
      additionalProperties: true
    InstallmentOffer:
      type: object
      properties:
        provider:
          type: string
        brand:
          type: string
        label:
          type: string
        parts:
          type: integer
        monthly:
          type: integer
        first:
          type: integer
        last:
          type: integer
        total:
          type: integer
        feePayer:
          type: string
          enum: [merchant, buyer]
    InstallmentProvider:
      type: object
      properties:
        provider:
          type: string
        brand:
          type: string
        label:
          type: string
        parts:
          type: array
          items:
            type: integer
        min:
          type: integer
        max:
          type: integer
        feePayer:
          type: string
    InstallmentQuote:
      type: object
      required: [enabled, currency, amount, offers, providers]
      properties:
        enabled:
          type: boolean
        currency:
          type: string
        amount:
          type: integer
        best:
          oneOf:
            - $ref: "#/components/schemas/InstallmentOffer"
            - type: "null"
        offers:
          type: array
          items:
            $ref: "#/components/schemas/InstallmentOffer"
        providers:
          type: array
          items:
            $ref: "#/components/schemas/InstallmentProvider"
    LeadInput:
      type: object
      properties:
        name:
          type: string
          maxLength: 80
        shop:
          type: string
          maxLength: 120
        email:
          type: string
          maxLength: 120
        phone:
          type: string
          maxLength: 60
        plan:
          type: string
          enum: [self-hosted, setup, support, hosting, other]
        message:
          type: string
          maxLength: 2000
        lang:
          type: string
          enum: [uk, en, ru]
        page:
          type: string
        website:
          type: string
          description: honeypot — leave empty
    Lead:
      type: object
      properties:
        id:
          type: integer
        ts:
          type: string
        name:
          type: string
        shop:
          type: string
        email:
          type: string
        phone:
          type: string
        plan:
          type: string
        message:
          type: string
        lang:
          type: [string, "null"]
        page:
          type: string
        ip:
          type: string
    PaymentItem:
      type: object
      properties:
        name:
          type: string
        code:
          type: string
        qty:
          type: integer
        unitAmount:
          type: integer
    ClientInfo:
      type: object
      properties:
        os:
          type: [string, "null"]
        osVer:
          type: [string, "null"]
        browser:
          type: [string, "null"]
        browserVer:
          type: [string, "null"]
        ip:
          type: [string, "null"]
        ipCountry:
          type: [string, "null"]
        langs:
          type: array
          items:
            type: string
        referer:
          type: [string, "null"]
        ua:
          type: [string, "null"]
    PaymentRecord:
      type: object
      description: one invoice (payment attempt) bound to a Shopify order or draft
      properties:
        orderId:
          type: string
        orderGid:
          type: string
        orderName:
          type: string
        statusPageUrl:
          type: string
        provider:
          type: string
        invoiceId:
          type: string
        pageUrl:
          type: string
          description: PSP payment page
        amount:
          type: integer
        ccy:
          type: [integer, "null"]
          description: ISO-4217 numeric
        currency:
          type: string
        status:
          type: string
          enum: [created, processing, hold, success, failure, reversed, expired]
        paidMarked:
          type: boolean
          description: Shopify order marked paid
        paidAt:
          type: [string, "null"]
        buyerEmail:
          type: [string, "null"]
        buyerPhone:
          type: [string, "null"]
        confirmation:
          type: [string, "null"]
        prepay:
          type: boolean
        orderTotal:
          type: [integer, "null"]
        remindedAt:
          type: [string, "null"]
        reminderCount:
          type: integer
        fiscalUrl:
          type: [string, "null"]
        kind:
          type: string
          enum: [order, draft]
        country:
          type: [string, "null"]
        region:
          type: [string, "null"]
        city:
          type: [string, "null"]
        device:
          type: [string, "null"]
        returnedAt:
          type: [string, "null"]
        failureReason:
          type: [string, "null"]
        qrId:
          type: [string, "null"]
        demo:
          type: boolean
        source:
          type: [string, "null"]
        campaign:
          type: [string, "null"]
        items:
          type: [array, "null"]
          items:
            $ref: "#/components/schemas/PaymentItem"
        buyerKey:
          type: [string, "null"]
        isRepeat:
          type: boolean
        fee:
          type: [integer, "null"]
        client:
          oneOf:
            - $ref: "#/components/schemas/ClientInfo"
            - type: "null"
        discounts:
          type: [object, "null"]
          additionalProperties: true
        recordAmount:
          type: [integer, "null"]
        recordCurrency:
          type: [string, "null"]
        paidEmailAt:
          type: [string, "null"]
        receiptEmailAt:
          type: [string, "null"]
        createdAt:
          type: string
        updatedAt:
          type: string
    FunnelEvent:
      type: object
      properties:
        id:
          type: integer
        ts:
          type: string
        order_id:
          type: string
        order_name:
          type: string
        stage:
          type: string
          description: order_created, pay_click, redirected, returned, paid, prepaid, failed, expired, reversed, refunded, cancelled, reminder_sent, fiscalized, np_branch …
        provider:
          type: [string, "null"]
        amount:
          type: [integer, "null"]
        currency:
          type: [string, "null"]
        country:
          type: [string, "null"]
        region:
          type: [string, "null"]
        city:
          type: [string, "null"]
        ip_country:
          type: [string, "null"]
        device:
          type: [string, "null"]
        lang:
          type: [string, "null"]
        reason:
          type: [string, "null"]
        meta:
          type: [string, "null"]
        source:
          type: [string, "null"]
        campaign:
          type: [string, "null"]
        buyer_key:
          type: [string, "null"]
        demo:
          type: integer
    Delivery:
      type: object
      description: outgoing CRM webhook delivery
      properties:
        id:
          type: string
          format: uuid
        event:
          type: string
        orderId:
          type: string
        orderName:
          type: string
        attempts:
          type: integer
        status:
          type: [integer, "null"]
          description: last HTTP status
        error:
          type: [string, "null"]
        state:
          type: string
          enum: [ok, retry, failed]
        nextAt:
          type: [string, "null"]
        lastAt:
          type: [string, "null"]
        createdAt:
          type: string
    QrRegister:
      type: object
      properties:
        qrId:
          type: string
        shortQrId:
          type: string
        amountType:
          type: string
          enum: [merchant, client, fix]
        pageUrl:
          type: string
        amount:
          type: [integer, "null"]
        ccy:
          type: [integer, "null"]
        invoiceId:
          type: [string, "null"]
        orderName:
          type: [string, "null"]
        orderId:
          type: [string, "null"]
        status:
          type: [string, "null"]
      additionalProperties: true
    FiscalReceipt:
      type: object
      properties:
        order_id:
          type: string
        source:
          type: string
          enum: [mono, checkbox, vchasno]
        receipt_id:
          type: [string, "null"]
        status:
          type: string
          enum: [pending, done, error]
        tax_url:
          type: [string, "null"]
        error:
          type: [string, "null"]
        created_at:
          type: string
        updated_at:
          type: [string, "null"]
        demo:
          type: integer
    KeyN:
      type: object
      properties:
        key:
          type: string
        orders:
          type: integer
        paid:
          type: integer
        revenue:
          type: integer
        conv:
          type: number
      additionalProperties: true
    Stats:
      type: object
      description: dashboard numbers for the period
      properties:
        days:
          type: integer
        since:
          type: string
        totals:
          type: object
          properties:
            orders:
              type: integer
            paid:
              type: integer
            conv:
              type: number
            revenue:
              type: integer
            aov:
              type: integer
            lost:
              type: integer
            currency:
              type: string
            repeat:
              type: integer
        funnel:
          type: array
          items:
            type: object
            properties:
              stage:
                type: string
              n:
                type: integer
        byCountry:
          type: array
          items:
            $ref: "#/components/schemas/KeyN"
        byRegion:
          type: array
          items:
            $ref: "#/components/schemas/KeyN"
        byCity:
          type: array
          items:
            $ref: "#/components/schemas/KeyN"
        byProvider:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              orders:
                type: integer
              paid:
                type: integer
              failed:
                type: integer
              conv:
                type: number
              revenue:
                type: integer
              medianMin:
                type: [number, "null"]
        byDevice:
          type: array
          items:
            $ref: "#/components/schemas/KeyN"
        byReason:
          type: array
          description: drop-off reasons with lost revenue
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              n:
                type: integer
              lost:
                type: integer
        byHour:
          type: array
          items:
            type: object
            properties:
              hour:
                type: integer
              paid:
                type: integer
        byDay:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              orders:
                type: integer
              paid:
                type: integer
              revenue:
                type: integer
        heat:
          type: array
          description: 7 × 24 matrix (weekday × hour) of paid orders
          items:
            type: array
            items:
              type: integer
        attention:
          type: array
          items:
            $ref: "#/components/schemas/Insight"
        stuck:
          type: array
          description: orders that clicked pay today and never came back
          items:
            type: object
            additionalProperties: true
        dropoffs:
          type: integer
        providerDaily:
          type: object
          additionalProperties:
            type: array
            items:
              type: integer
    Insight:
      type: object
      properties:
        level:
          type: string
          enum: [info, warn, crit, ok]
        text:
          type: string
    Delta:
      type: object
      properties:
        value:
          type: number
        prev:
          type: number
        delta:
          type: [number, "null"]
    Analytics:
      type: object
      properties:
        days:
          type: integer
        since:
          type: string
        currency:
          type: string
        totals:
          type: object
          properties:
            orders:
              $ref: "#/components/schemas/Delta"
            paid:
              $ref: "#/components/schemas/Delta"
            revenue:
              $ref: "#/components/schemas/Delta"
            aov:
              $ref: "#/components/schemas/Delta"
            conv:
              $ref: "#/components/schemas/Delta"
            buyers:
              $ref: "#/components/schemas/Delta"
        bySource:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              orders:
                type: integer
              paid:
                type: integer
              conv:
                type: number
              revenue:
                type: integer
              aov:
                type: integer
        byCampaign:
          type: array
          items:
            $ref: "#/components/schemas/KeyN"
        buyers:
          type: object
          properties:
            newCount:
              type: integer
            repeatCount:
              type: integer
            newRevenue:
              type: integer
            repeatRevenue:
              type: integer
            newAov:
              type: integer
            repeatAov:
              type: integer
            repeatShare:
              type: number
        topBuyers:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              orders:
                type: integer
              revenue:
                type: integer
              last:
                type: string
              country:
                type: [string, "null"]
        products:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              name:
                type: string
              qty:
                type: integer
              revenue:
                type: integer
              orders:
                type: integer
        basket:
          type: number
          description: average items per paid order
        timeToPay:
          type: object
          properties:
            medianMin:
              type: [number, "null"]
            p90Min:
              type: [number, "null"]
            hist:
              type: array
              items:
                type: object
                properties:
                  label:
                    type: string
                  n:
                    type: integer
        reminders:
          type: object
          properties:
            sent:
              type: integer
            recovered:
              type: integer
            rate:
              type: number
            revenue:
              type: integer
        byWeekday:
          type: array
          items:
            type: object
            properties:
              label:
                type: string
              paid:
                type: integer
              revenue:
                type: integer
        matrix:
          type: object
          description: country × provider
          properties:
            providers:
              type: array
              items:
                type: string
            rows:
              type: array
              items:
                type: object
                properties:
                  country:
                    type: string
                  cells:
                    type: array
                    items:
                      type: object
                      properties:
                        provider:
                          type: string
                        orders:
                          type: integer
                        paid:
                          type: integer
                        conv:
                          type: number
        cohorts:
          type: array
          items:
            type: object
            properties:
              week:
                type: integer
              buyers:
                type: integer
              again:
                type: integer
              label:
                type: string
              rate:
                type: number
        fees:
          type: array
          items:
            type: object
            properties:
              provider:
                type: string
              paid:
                type: integer
              revenue:
                type: integer
              fee:
                type: integer
              feeReported:
                type: integer
              feeEstimated:
                type: integer
              reportedShare:
                type: number
              rate:
                type: number
              assumedRate:
                type: [number, "null"]
              net:
                type: integer
        feesSummary:
          type: object
          properties:
            total:
              type: integer
            rate:
              type: number
            net:
              type: integer
            reportedShare:
              type: number
        byDiscount:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              orders:
                type: integer
              paid:
                type: integer
              revenue:
                type: integer
              discount:
                type: integer
              conv:
                type: number
              aov:
                type: integer
        discounts:
          type: object
          properties:
            total:
              type: integer
            share:
              type: number
        insights:
          type: array
          items:
            $ref: "#/components/schemas/Insight"
    Health:
      type: object
      required: [status, version, db, providers]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        version:
          type: string
        uptimeSec:
          type: integer
        db:
          type: string
          description: "`ok` or the SQLite error"
        providers:
          type: array
          items:
            type: string
        fiscal:
          type: string
          enum: [none, mono, checkbox, vchasno]
        events:
          type: boolean
        keycrm:
          type: boolean
        demo:
          type: boolean
        last24h:
          type: object
          properties:
            paid:
              type: [integer, "null"]
            created:
              type: [integer, "null"]
            failedDeliveries:
              type: [integer, "null"]
            failedRefunds:
              type: [integer, "null"]
            pendingFiscal:
              type: [integer, "null"]
        lastWebhookAt:
          type: [string, "null"]
    AuditRow:
      type: object
      properties:
        id:
          type: integer
        ts:
          type: string
        user_id:
          type: [integer, "null"]
        login:
          type: [string, "null"]
        role:
          type: [string, "null"]
        action:
          type: string
        target:
          type: [string, "null"]
        details:
          type: [string, "null"]
          description: JSON string with safe form fields
        ip:
          type: [string, "null"]
        status:
          type: [integer, "null"]
    AuditList:
      type: object
      properties:
        total:
          type: integer
        rows:
          type: array
          items:
            $ref: "#/components/schemas/AuditRow"
    SetupCheck:
      type: object
      properties:
        id:
          type: string
        group:
          type: string
        title:
          type: string
        hint:
          type: string
        ms:
          type: integer
        state:
          type: string
          enum: [ok, warn, fail, skip]
        detail:
          type: string
    SetupReport:
      type: object
      properties:
        at:
          type: string
        results:
          type: array
          items:
            $ref: "#/components/schemas/SetupCheck"
        summary:
          type: [object, "null"]
          properties:
            ok:
              type: integer
            warn:
              type: integer
            fail:
              type: integer
            skip:
              type: integer
    WebhookLogRow:
      type: object
      properties:
        id:
          type: integer
        ts:
          type: string
        source:
          type: string
        topic:
          type: [string, "null"]
        ok:
          type: boolean
        status:
          type: [integer, "null"]
          description: our HTTP reply
        external_id:
          type: [string, "null"]
        order_id:
          type: [string, "null"]
        ip:
          type: [string, "null"]
        bytes:
          type: [integer, "null"]
        headers:
          type: object
          additionalProperties: true
        body:
          type: [string, "null"]
        detail:
          type: [string, "null"]
        ms:
          type: [integer, "null"]
    WebhookLogList:
      type: object
      properties:
        total:
          type: integer
        rows:
          type: array
          items:
            $ref: "#/components/schemas/WebhookLogRow"
    Security:
      type: object
      properties:
        fails:
          type: object
          properties:
            byIp:
              type: array
              items:
                $ref: "#/components/schemas/FailCount"
            byLogin:
              type: array
              items:
                $ref: "#/components/schemas/FailCount"
            total:
              type: integer
            denied:
              type: integer
        locked:
          type: array
          items:
            type: object
            properties:
              ip:
                type: string
              until:
                type: string
            additionalProperties: true
        sessions:
          type: array
          items:
            type: object
            properties:
              login:
                type: string
              ip:
                type: string
              createdAt:
                type: string
              lastSeen:
                type: string
              expiresAt:
                type: string
    FailCount:
      type: object
      properties:
        k:
          type: [string, "null"]
        n:
          type: integer
        last:
          type: string
    Me:
      type: object
      properties:
        login:
          type: string
        name:
          type: string
        role:
          $ref: "#/components/schemas/Role"
        mode:
          type: string
          enum: [key, cookie]
    SelftestReport:
      type: object
      properties:
        ok:
          type: boolean
        at:
          type: string
        orderId:
          type: [string, "null"]
        orderName:
          type: string
        url:
          type: [string, "null"]
        steps:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              ok:
                type: boolean
              detail:
                type: string
    ReconcileReport:
      type: object
      properties:
        since:
          type: string
        checked:
          type: object
          properties:
            invoices:
              type: integer
            orders:
              type: integer
        healed:
          type: integer
        issues:
          type: array
          items:
            type: object
            properties:
              kind:
                type: string
              text:
                type: string
            additionalProperties: true
        at:
          type: string
    SettingsExport:
      type: object
      properties:
        version:
          type: string
        exportedAt:
          type: string
        shop:
          type: [string, "null"]
        settings:
          type: object
          description: admin overrides (CONFIG_KEYS only)
          additionalProperties:
            type: string
        phoneRules:
          type: array
          items:
            type: object
            properties:
              phone:
                type: string
              mode:
                type: string
                enum: [prepay, block, trusted]
              note:
                type: string
        gatewayMapEffective:
          type: object
          additionalProperties:
            type: object
            properties:
              provider:
                type: string
              prepay:
                type: integer
              prepayPercent:
                type: number
    LinkInput:
      type: object
      properties:
        draftId:
          type: string
          description: existing Shopify draft order id (then title/amount are ignored)
        title:
          type: string
          maxLength: 200
        amount:
          type: string
          description: major units, `.` or `,` decimal (e.g. `1250.50`)
        email:
          type: string
        phone:
          type: string
        provider:
          type: string
          description: provider key, default LINK_PROVIDER
    LinkCreated:
      type: object
      properties:
        url:
          type: string
          format: uri
        token:
          type: string
        draft:
          type: string
          description: draft order name
        amount:
          type: integer
    DataSignature:
      type: object
      required: [data, signature]
      properties:
        data:
          type: string
          description: base64-encoded JSON
        signature:
          type: string
    PrivatpartsCallback:
      type: object
      required: [orderId, paymentState, signature]
      properties:
        storeId:
          type: string
        orderId:
          type: string
        paymentState:
          type: string
          enum: [CREATED, SUCCESS, FAIL, CANCELED, LOCKED, CLIENT_WAIT, OTP_WAITING, PP_CREATION, LOCKED_WAIT]
        message:
          type: string
        signature:
          type: string
