openapi: 3.0.3
info:
  title: Kaspi POS Automation — Public API
  version: '1.0'
  description: |
    Drop-in REST API compatible with apipay.kz. Authenticate with
    `X-API-Key`. Per-tenant rate limit is 60 requests per minute.
    All errors use the apipay envelope `{ error: { code, message, fields? } }`.
    Canonical invoice statuses: `pending`, `cancelling`, `paid`,
    `cancelled`, `expired`, `partially_refunded`, `refunded`.
  contact:
    name: Kaspi POS Automation
    url: https://github.com/tapter-dev/kaspi-pos-automation
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: http://localhost:3000/api/v1
    description: Local development server
  - url: https://<your-host>/api/v1
    description: Production deployment (replace placeholder)

security:
  - ApiKeyAuth: []

tags:
  - name: Health
    description: Liveness probe (unauthenticated)
  - name: Account
    description: Tenant + API-key introspection
  - name: Invoices
    description: Phone-based invoices and QR invoices
  - name: Refunds
    description: Refund accounting
  - name: Catalog
    description: Per-tenant catalog of products and images
  - name: Subscriptions
    description: |
      Recurring billing rules. Each subscription has its own state machine
      (`active` / `paused` / `cancelled` / `expired`) and is billed by an
      internal scheduler that creates invoices on the configured cadence.
      Subscription-driven invoices ride the existing
      `invoice.status_changed` webhook with an additive `subscription_id`
      field on the `invoice` payload.
  - name: Webhooks
    description: Webhook configuration and test
  - name: Dashboard
    description: |
      Internal first-party surface mounted at `/api/dashboard/v1/*`.
      Used by our merchant dashboard SPA at `/app/`. **NOT part of the
      apipay drop-in contract** — integrators must use `/api/v1/*` with
      `X-API-Key`. Auth: HMAC-signed session cookie (`kpa_session`) +
      double-submit CSRF (`csrf` cookie + `X-CSRF-Token` header on every
      state-changing request). Stability not guaranteed between
      releases — SPA and server ship in lockstep.

paths:
  /health:
    get:
      tags: [Health]
      summary: Liveness probe
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  api: { type: string, example: v1 }

  /me:
    get:
      tags: [Account]
      summary: Current tenant + API key
      operationId: getMe
      responses:
        '200':
          description: Tenant + API-key metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant: { type: object }
                  api_key:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      is_sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      operationId: listInvoices
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/InvoiceStatus' } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: A page of invoices
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Invoices]
      summary: Create a phone-based invoice
      operationId: createInvoice
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceCreate' }
      responses:
        '201':
          description: Invoice created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceCreated' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KaspiSessionRequired' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /invoices/qr:
    post:
      tags: [Invoices]
      summary: Create a QR invoice
      operationId: createQrInvoice
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 0.01, maximum: 99999999.99 }
                description: { type: string, maxLength: 500 }
                external_order_id: { type: string }
                latitude: { type: number }
                longitude: { type: number }
                is_sandbox: { type: boolean }
                simulate:
                  type: string
                  enum: [paid, cancelled, expired]
      responses:
        '201':
          description: QR invoice created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceCreated' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/KaspiSessionRequired' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /invoices/status/check:
    post:
      tags: [Invoices]
      summary: Bulk status verification
      operationId: checkInvoiceStatuses
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_ids]
              properties:
                invoice_ids:
                  type: array
                  items: { type: integer }
      responses:
        '200':
          description: Status snapshot for each id
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        status: { type: string }
                        error: { type: string, nullable: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /invoices/{id}:
    get:
      tags: [Invoices]
      summary: Invoice detail
      operationId: getInvoice
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Invoice
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices/{id}/cancel:
    post:
      tags: [Invoices]
      summary: Cancel a pending invoice
      operationId: cancelInvoice
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Updated invoice
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invoice is not in a cancellable state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /invoices/{id}/refund:
    post:
      tags: [Invoices]
      summary: Refund a paid invoice (full or partial)
      operationId: refundInvoice
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefundCreate' }
      responses:
        '201':
          description: Refund created
          content:
            application/json:
              schema:
                type: object
                properties:
                  refund: { $ref: '#/components/schemas/Refund' }
                  invoice: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /invoices/{id}/refunds:
    get:
      tags: [Invoices]
      summary: List refunds for an invoice
      operationId: listInvoiceRefunds
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Refunds for the invoice
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Refund' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /refunds:
    get:
      tags: [Refunds]
      summary: List all refunds for the tenant
      operationId: listRefunds
      responses:
        '200':
          description: All refunds for the authenticated tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Refund' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /catalog/units:
    get:
      tags: [Catalog]
      summary: Measurement units
      operationId: listCatalogUnits
      responses:
        '200':
          description: Units list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogUnit' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /catalog:
    get:
      tags: [Catalog]
      summary: List catalog items
      operationId: listCatalogItems
      parameters:
        - { name: search, in: query, schema: { type: string } }
        - { name: is_active, in: query, schema: { type: boolean } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Page of catalog items
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogItem' }
                  total: { type: integer }
                  page: { type: integer }
                  per_page: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Catalog]
      summary: Batch create catalog items
      operationId: createCatalogItems
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - { $ref: '#/components/schemas/CatalogItemCreate' }
                - type: array
                  items: { $ref: '#/components/schemas/CatalogItemCreate' }
                - type: object
                  properties:
                    items:
                      type: array
                      items: { $ref: '#/components/schemas/CatalogItemCreate' }
      responses:
        '201':
          description: Created catalog items
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /catalog/upload-image:
    post:
      tags: [Catalog]
      summary: Upload a catalog image (multipart/form-data)
      operationId: uploadCatalogImage
      description: |
        Binary upload via `multipart/form-data`. The file MUST be sent in
        the `file` field. Maximum size: 5 MB. Accepted MIME types:
        `image/jpeg`, `image/png`, `image/webp`. The server generates the
        filename — client-supplied filenames are discarded.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: Image accepted and stored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogImageUpload' }
        '400':
          description: Content-Type is not multipart/form-data
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /catalog/images/{imageId}:
    get:
      tags: [Catalog]
      summary: Serve a stored catalog image
      operationId: getCatalogImage
      parameters:
        - { name: imageId, in: path, required: true, schema: { type: string, pattern: '^[a-zA-Z0-9_-]{1,64}$' } }
      responses:
        '200':
          description: Image bytes
          content:
            image/jpeg: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
            image/webp: { schema: { type: string, format: binary } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invalid image id format
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /catalog/{id}:
    get:
      tags: [Catalog]
      summary: Catalog item detail
      operationId: getCatalogItem
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Catalog item
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Catalog]
      summary: Update a catalog item
      operationId: updateCatalogItem
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogItemCreate' }
      responses:
        '200':
          description: Updated catalog item
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Catalog]
      summary: Soft-delete a catalog item
      operationId: deleteCatalogItem
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Item soft-deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  item: { $ref: '#/components/schemas/CatalogItem' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions:
    get:
      tags: [Subscriptions]
      summary: List subscriptions
      operationId: listSubscriptions
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 10 } }
        - name: status
          in: query
          schema:
            oneOf:
              - { $ref: '#/components/schemas/SubscriptionStatus' }
              - { type: string, description: 'CSV of statuses, e.g. `active,paused`' }
        - { name: phone_number, in: query, schema: { type: string, description: 'Digit substring match' } }
        - { name: external_subscriber_id, in: query, schema: { type: string } }
        - { name: search, in: query, schema: { type: string, description: 'Substring match across phone / subscriber_name / external_subscriber_id / description' } }
      responses:
        '200':
          description: Page of subscriptions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Subscriptions]
      summary: Create a subscription
      description: |
        Creates a recurring-billing rule. Either `amount` or `cart_items`
        MUST be supplied (XOR). `cart_items` resolves to an immutable
        snapshot of `{ catalog_item_id, name, price, count, unit, image_id }`
        at creation time — subsequent catalog changes do not affect the
        subscription. `external_subscriber_id` is a per-tenant
        idempotency hint: collisions against `active` / `paused`
        subscriptions of the same tenant return 409 `SUBSCRIPTION_EXISTS`.
        `bill_immediately: true` sets `next_billing_at = now()` so the
        next scheduler tick (≤60 s) creates the first invoice.
      operationId: createSubscription
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubscriptionCreate' }
      responses:
        '201':
          description: Subscription created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: |
            Either `KASPI_SESSION_REQUIRED` (live key without Kaspi session)
            or `SUBSCRIPTION_EXISTS` (idempotency collision on
            `external_subscriber_id`). The response body's `error.code`
            distinguishes the two.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions/{id}:
    get:
      tags: [Subscriptions]
      summary: Subscription detail with stats + last_payment
      operationId: getSubscription
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Subscription detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Subscriptions]
      summary: Update mutable subscription fields
      description: |
        Updates the mutable subset of fields only — `amount`, `billing_day`,
        `description`, `subscriber_name`, `external_subscriber_id`,
        `max_retry_attempts`, `retry_interval_hours`, `grace_period_days`,
        `metadata`, `cart_items`. Any other field is rejected by the
        server-side whitelist. Disallowed on `cancelled` / `expired`
        subscriptions. Changes do NOT mutate in-flight pending invoices —
        the next billing cycle picks up the new values.
      operationId: updateSubscription
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubscriptionUpdate' }
      responses:
        '200':
          description: Updated subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            `SUBSCRIPTION_EXISTS` — the new `external_subscriber_id`
            collides with another active/paused subscription of the same
            tenant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions/{id}/pause:
    post:
      tags: [Subscriptions]
      summary: Pause an active subscription
      description: |
        Transitions `active` → `paused`. Freezes `next_billing_at`. Returns
        422 `INVALID_STATE_TRANSITION` when the subscription is not
        `active`.
      operationId: pauseSubscription
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Subscription paused
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Illegal state transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions/{id}/resume:
    post:
      tags: [Subscriptions]
      summary: Resume a paused subscription
      description: |
        Transitions `paused` → `active`. Recomputes
        `next_billing_at = now() + 1 period unit` (no back-billing for the
        gap during pause). Returns 422 `INVALID_STATE_TRANSITION` when the
        subscription is not `paused`.
      operationId: resumeSubscription
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Subscription resumed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Illegal state transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions/{id}/cancel:
    post:
      tags: [Subscriptions]
      summary: Cancel a subscription (terminal)
      description: |
        Transitions `active` / `paused` → `cancelled`. Terminal. Returns
        422 `INVALID_STATE_TRANSITION` on already-terminal subscriptions.
      operationId: cancelSubscription
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Subscription cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Illegal state transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /subscriptions/{id}/invoices:
    get:
      tags: [Subscriptions]
      summary: List billing cycles (invoices) for a subscription
      operationId: listSubscriptionInvoices
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        '200':
          description: |
            Page of link rows. Each row joins the billing-cycle metadata
            (period bounds, attempt number, link-row status) with the
            invoice that was created for the cycle.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/SubscriptionInvoice' }
                  meta: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhook/configure:
    get:
      tags: [Webhooks]
      summary: Get the tenant's webhook configuration
      operationId: getWebhookConfig
      responses:
        '200':
          description: Webhook configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Webhooks]
      summary: Configure the tenant's webhook
      operationId: setWebhookConfig
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                  description: |
                    Must be `http://` or `https://`. Private addresses
                    (loopback `127.0.0.0/8`, link-local `169.254.0.0/16`,
                    RFC1918, CGNAT `100.64.0.0/10`) are rejected with
                    `VALIDATION_ERROR`. Self-hosted operators can opt out
                    via the `ALLOW_PRIVATE_WEBHOOK_URLS=1` env var.
                enabled: { type: boolean }
                events:
                  type: array
                  items: { type: string }
                rotate_secret:
                  type: boolean
                  default: false
                  description: |
                    When `true`, the server generates a new webhook secret
                    and returns it in the response under `webhookSecret`.
                    Otherwise the existing secret is preserved and the
                    response omits `webhookSecret`.
      responses:
        '200':
          description: Updated webhook configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant: { type: object }
                  webhookSecret:
                    type: string
                    description: |
                      Returned ONLY on first-time creation OR when the
                      request body sets `rotate_secret: true`. Routine
                      updates (enable/disable, URL change) do NOT echo
                      the secret.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhook/test:
    post:
      tags: [Webhooks]
      summary: Send a `webhook.test` event to the configured URL
      operationId: testWebhook
      responses:
        '200':
          description: Delivery outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  payload: { type: object }
                  delivery: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─── Dashboard API ──────────────────────────────────────────────────────
  # Internal — used by our merchant dashboard at `/app/`, not part of the
  # apipay drop-in surface. All paths below are rooted at
  # `/api/dashboard/v1` in production. Auth: signed session cookie +
  # double-submit CSRF.

  /dashboard/v1/signup:
    post:
      tags: [Dashboard]
      summary: Create user + tenant + first API key
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Anonymous; per-IP rate-limited
        (`SIGNUP_LOGIN_RPM`, default 10/min). Sets `kpa_session` + `csrf`
        cookies. The first API key's `secret` is returned ONCE — store
        it immediately.
      operationId: dashboardSignup
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignupRequest' }
      responses:
        '201':
          description: User, tenant, and first API key created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SignupResponse' }
        '409':
          description: Email already registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error: { code: EMAIL_TAKEN, message: Email already registered. }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /dashboard/v1/login:
    post:
      tags: [Dashboard]
      summary: Verify credentials, issue session cookie
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Anonymous; per-IP rate-limited PLUS
        per-email lockout (10 fails / 15 min → 15 min `LOCKED` with
        `Retry-After: 900`). Wrong email and wrong password return the
        same `401 INVALID_CREDENTIALS` envelope to defeat enumeration.
      operationId: dashboardLogin
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
      responses:
        '200':
          description: Authenticated; cookies set
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { type: object }
                  tenant: { type: object }
                  csrf_token: { type: string }
        '401':
          description: Invalid email or password (anti-enumeration)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error: { code: INVALID_CREDENTIALS, message: Invalid email or password. }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429':
          description: |
            Either per-IP `RATE_LIMITED` or per-email `LOCKED` (15-min
            lockout after 10 fails in 15 min). `Retry-After` header carries
            the wait time in seconds.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: LOCKED
                  message: Account temporarily locked due to too many failed login attempts.
                  fields: { retry_after_seconds: '900' }

  /dashboard/v1/logout:
    post:
      tags: [Dashboard]
      summary: Clear session + CSRF cookies
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. CSRF required to defeat cross-site
        logout.
      operationId: dashboardLogout
      security:
        - cookieAuth: []
          csrfHeader: []
      responses:
        '200':
          description: Cookies cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
        '403': { $ref: '#/components/responses/CsrfMismatch' }

  /dashboard/v1/me:
    get:
      tags: [Dashboard]
      summary: Current user + tenant snapshot
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. The bootstrap call. Returns
        `tenant.kaspi_session.phone_masked` only (`8XX***4567`) — raw
        phone is never echoed on this surface.
      operationId: dashboardMe
      security:
        - cookieAuth: []
      responses:
        '200':
          description: User + tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { type: object }
                  tenant: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /dashboard/v1/password:
    post:
      tags: [Dashboard]
      summary: Change password
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Verifies `current_password`
        timing-safely. On success re-issues BOTH the session cookie and
        the CSRF token — defends against cookies leaked before the
        change.
      operationId: dashboardChangePassword
      security:
        - cookieAuth: []
          csrfHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PasswordChangeRequest' }
      responses:
        '200':
          description: Password updated; cookies re-issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  csrf_token: { type: string }
        '401':
          description: |
            Either no session (`UNAUTHORIZED`) or wrong current password
            (`INVALID_CREDENTIALS`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /dashboard/v1/api-keys:
    get:
      tags: [Dashboard]
      summary: List the tenant's API keys (no secrets)
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Public projection only — no secrets,
        no hashes.
      operationId: dashboardListApiKeys
      security:
        - cookieAuth: []
      responses:
        '200':
          description: API keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Dashboard]
      summary: Issue a new API key (secret returned ONCE)
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. The raw `secret` is shown ONCE; the
        server retains only the SHA-256 hash.
      operationId: dashboardCreateApiKey
      security:
        - cookieAuth: []
          csrfHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyCreateRequest' }
      responses:
        '201':
          description: API key created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreateResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /dashboard/v1/api-keys/{id}/rotate:
    post:
      tags: [Dashboard]
      summary: Atomically rotate an API key (new secret ONCE)
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Revokes `:id` and issues a
        replacement key with the same `name` and `mode`. Cross-tenant id
        → 404.
      operationId: dashboardRotateApiKey
      security:
        - cookieAuth: []
          csrfHeader: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: New key issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreateResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '404': { $ref: '#/components/responses/NotFound' }

  /dashboard/v1/api-keys/{id}/revoke:
    post:
      tags: [Dashboard]
      summary: Revoke an API key (idempotent)
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Re-revoking does NOT advance
        `revoked_at`. Cross-tenant id → 404.
      operationId: dashboardRevokeApiKey
      security:
        - cookieAuth: []
          csrfHeader: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '404': { $ref: '#/components/responses/NotFound' }

  /dashboard/v1/kaspi/sms/request:
    post:
      tags: [Dashboard]
      summary: Start Kaspi SMS flow (step 1 + 2)
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Drives Kaspi `init` + `send-phone`
        on behalf of the logged-in merchant. The server-side stash holds
        Kaspi's `processId` for 5 minutes; the client never sees it.
      operationId: dashboardKaspiSmsRequest
      security:
        - cookieAuth: []
          csrfHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KaspiSmsRequestBody' }
      responses:
        '200':
          description: Kaspi accepted the phone; OTP sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  expires_in: { type: integer, example: 300 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '422':
          description: |
            Phone number rejected (`PHONE_INVALID`) or Kaspi declined the
            SMS request (`SMS_REJECTED`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '502':
          description: Kaspi upstream `send-phone` failure (`SMS_REQUEST_FAILED`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /dashboard/v1/kaspi/sms/confirm:
    post:
      tags: [Dashboard]
      summary: Confirm Kaspi SMS code (step 3) and persist session
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Calls Kaspi `verify-otp` +
        `finish-entrance`, encrypts the `vtokenSecret`, persists the
        Kaspi session onto the tenant record. Stash is dropped on success
        AND on failure.
      operationId: dashboardKaspiSmsConfirm
      security:
        - cookieAuth: []
          csrfHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KaspiSmsConfirmBody' }
      responses:
        '200':
          description: Session persisted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  kaspi: { $ref: '#/components/schemas/KaspiStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }
        '410':
          description: |
            No `/sms/request` preceded this call, or the 5-minute stash
            TTL has elapsed (`SMS_FLOW_EXPIRED`). Restart the wizard.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422':
          description: SMS code rejected by Kaspi (`SMS_CODE_INVALID`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '502':
          description: |
            Kaspi `finish-entrance` returned no usable session
            (`KASPI_SESSION_REJECTED`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /dashboard/v1/kaspi/disconnect:
    post:
      tags: [Dashboard]
      summary: Clear Kaspi session; pause active subscriptions
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Nulls `tenant.kaspi_session` fields
        and transitions every `active` subscription on this tenant to
        `paused` (don't bill while disconnected).
      operationId: dashboardKaspiDisconnect
      security:
        - cookieAuth: []
          csrfHeader: []
      responses:
        '200':
          description: Disconnected
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/CsrfMismatch' }

  /dashboard/v1/kaspi/status:
    get:
      tags: [Dashboard]
      summary: Read-only Kaspi connection snapshot
      description: |
        Internal — used by our merchant dashboard at `/app/`, not part of
        the apipay drop-in surface. Never echoes `tokenSN` or
        `vtokenSecret`; phone is masked (`8XX***4567`).
      operationId: dashboardKaspiStatus
      security:
        - cookieAuth: []
      responses:
        '200':
          description: Connection status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KaspiStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Per-tenant API key. Sandbox keys are prefixed `kpa_test_*` and
        carry `is_sandbox: true` on the key record; live keys are
        prefixed `kpa_live_*`. Comparisons are timing-safe.
    cookieAuth:
      type: apiKey
      in: cookie
      name: kpa_session
      description: |
        HMAC-signed session cookie used by the internal `/api/dashboard/v1/*`
        surface. Payload is `{ user_id, exp }` signed with HMAC-SHA256
        over `SESSION_SECRET`. Attributes: `HttpOnly; Secure;
        SameSite=Lax; Path=/`. TTL defaults to 7 days
        (`SESSION_TTL_SECONDS`). NOT used on `/api/v1/*`.
    csrfHeader:
      type: apiKey
      in: header
      name: X-CSRF-Token
      description: |
        Double-submit CSRF token for the internal dashboard surface.
        Value must match the `csrf` cookie (timing-safe compare).
        Required on every state-changing dashboard request (POST/PUT/DELETE);
        GET endpoints are exempt. NOT used on `/api/v1/*`.

  responses:
    Unauthorized:
      description: Missing or invalid `X-API-Key`
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: UNAUTHORIZED
              message: Invalid or missing API key.
    NotFound:
      description: Resource not found (or not visible to this tenant)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: NOT_FOUND
              message: Invoice not found.
    ValidationError:
      description: Field validation failed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: VALIDATION_ERROR
              message: Validation failed
              fields:
                amount: Amount must be greater than 0.
    RateLimited:
      description: Tenant rate limit exceeded (60 RPM)
      headers:
        Retry-After:
          description: Seconds until the bucket refills.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: RATE_LIMITED
              message: 'Rate limit exceeded: 60 requests per minute.'
    KaspiSessionRequired:
      description: |
        A `kpa_live_*` key was used but the tenant has no Kaspi session
        configured. Connect Kaspi Business as a cashier before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: KASPI_SESSION_REQUIRED
              message: Kaspi session is not configured for this tenant.
    UpstreamError:
      description: |
        Upstream payment provider (Kaspi) error. The `fields.request_id`
        value correlates the response with the server-side log entry that
        captured the underlying upstream message. Quote this `request_id`
        when contacting support.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: UPSTREAM_ERROR
              message: Upstream payment provider error.
              fields:
                request_id: 9f3a1b2c4d5e6f70
    CsrfMismatch:
      description: |
        Missing or non-matching `X-CSRF-Token` header on a state-changing
        dashboard request. Refresh the page or re-bootstrap the session
        to read the latest `csrf` cookie value.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: CSRF_MISMATCH
              message: CSRF token mismatch.

  schemas:
    InvoiceStatus:
      type: string
      enum:
        - pending
        - cancelling
        - paid
        - cancelled
        - expired
        - partially_refunded
        - refunded
      description: |
        Canonical apipay invoice status. Terminal: `paid`, `cancelled`,
        `expired`, `partially_refunded`, `refunded`. Non-terminal:
        `pending`, `cancelling`.

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - BAD_REQUEST
                - UNAUTHORIZED
                - NOT_FOUND
                - KASPI_SESSION_REQUIRED
                - SUBSCRIPTION_EXISTS
                - GONE
                - VALIDATION_ERROR
                - INVALID_STATE_TRANSITION
                - RATE_LIMITED
                - INTERNAL
                - INTERNAL_ERROR
                - UPSTREAM_ERROR
                # Dashboard-only codes (`/api/dashboard/v1/*`):
                - CSRF_MISMATCH
                - EMAIL_TAKEN
                - INVALID_CREDENTIALS
                - LOCKED
                - SMS_FLOW_EXPIRED
                - SMS_CODE_INVALID
                - SMS_REJECTED
                - SMS_REQUEST_FAILED
                - PHONE_INVALID
                - KASPI_SESSION_REJECTED
              example: VALIDATION_ERROR
            message: { type: string }
            fields:
              type: object
              description: |
                Per-field validation messages, or, for 502
                `UPSTREAM_ERROR`, the `request_id` correlation token used
                by support. All values are strings.
              additionalProperties: { type: string }
              properties:
                request_id:
                  type: string
                  description: |
                    Present on 502 `UPSTREAM_ERROR` responses. Hex token
                    that correlates the response with the server-side log
                    of the underlying upstream error.

    InvoiceCreate:
      type: object
      required: [amount, phone_number]
      properties:
        amount:
          type: number
          format: float
          minimum: 0.01
          maximum: 99999999.99
        phone_number:
          type: string
          pattern: '^8\d{10}$'
          example: '87001234567'
        description: { type: string, maxLength: 500 }
        external_order_id: { type: string, description: Idempotency hint }
        is_sandbox: { type: boolean }
        simulate:
          type: string
          enum: [paid, cancelled, expired]
          description: Sandbox-only — pre-set the final status.

    InvoiceCreated:
      type: object
      properties:
        id: { type: integer, example: 42 }
        external_order_id: { type: string, nullable: true }
        amount: { type: string, example: '15000.00' }
        status: { $ref: '#/components/schemas/InvoiceStatus' }
        created_at: { type: string, format: date-time }

    Invoice:
      type: object
      properties:
        id: { type: integer }
        external_order_id: { type: string, nullable: true }
        amount: { type: string, description: Decimal string with 2 decimals }
        total_refunded: { type: string }
        is_fully_refunded: { type: boolean }
        description: { type: string, nullable: true }
        status: { $ref: '#/components/schemas/InvoiceStatus' }
        paid_at: { type: string, format: date-time, nullable: true }
        is_sandbox: { type: boolean }
        is_recurring: { type: boolean }
        kaspi_invoice_id: { type: string, nullable: true }
        client_name: { type: string, nullable: true }
        client_phone: { type: string, nullable: true }
        client_comment: { type: string, nullable: true }
        subscription_id:
          type: integer
          nullable: true
          description: |
            Set when this invoice was created by the subscription
            scheduler. Absent (omitted from the JSON, not `null`) on
            one-off invoices — drop-in compatibility for non-subscription
            consumers is preserved.
        created_at: { type: string, format: date-time }
        items:
          type: array
          items: { type: object }

    InvoiceList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Invoice' }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    RefundCreate:
      type: object
      properties:
        amount:
          type: number
          minimum: 0.01
          description: Defaults to the full available-for-refund balance.
        reason: { type: string }

    Refund:
      type: object
      properties:
        id: { type: integer }
        invoice_id: { type: integer }
        amount: { type: string }
        status: { type: string }
        reason: { type: string, nullable: true }
        is_sandbox: { type: boolean }
        created_at: { type: string, format: date-time }

    CatalogUnit:
      type: object
      properties:
        code: { type: string }
        name_ru: { type: string }
        name_kk: { type: string }
        name_en: { type: string }
        precision: { type: integer }

    CatalogItemCreate:
      type: object
      required: [name, price]
      properties:
        sku: { type: string }
        name: { type: string }
        price: { type: number, minimum: 0.01 }
        unit: { type: string }
        image_id: { type: string, nullable: true }
        description: { type: string }

    CatalogItem:
      type: object
      properties:
        id: { type: integer }
        sku: { type: string, nullable: true }
        name: { type: string }
        price: { type: string }
        unit: { type: string }
        image_id: { type: string, nullable: true }
        image_url: { type: string, nullable: true }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    CatalogImageUpload:
      type: object
      properties:
        image_id: { type: string, description: 'Server-generated id, matches `^[a-zA-Z0-9_-]{1,64}$`' }
        url: { type: string, example: /api/v1/catalog/images/<image_id> }
        mime: { type: string, example: image/jpeg }
        size: { type: integer, description: Bytes }
        checksum: { type: string, description: SHA-256 hex }

    Pagination:
      type: object
      description: Apipay-shape pagination metadata.
      properties:
        current_page: { type: integer, example: 1 }
        last_page: { type: integer, example: 3 }
        per_page: { type: integer, example: 20 }
        total: { type: integer, example: 47 }

    BillingPeriod:
      type: string
      enum: [daily, weekly, biweekly, monthly, quarterly, yearly]
      description: |
        Subscription billing cadence. `daily` / `weekly` / `biweekly` are
        UTC arithmetic. `monthly` / `quarterly` / `yearly` use the
        `billing_day` field at 00:00 Almaty (UTC+5) local time, clamped to
        `min(28, lastDayOfMonth)` for February safety.

    SubscriptionStatus:
      type: string
      enum: [active, paused, cancelled, expired]
      description: |
        Subscription state machine. Terminal: `cancelled`, `expired`.
        Non-terminal: `active`, `paused`.

    SubscriptionCartItemInput:
      type: object
      required: [catalog_item_id, count]
      properties:
        catalog_item_id: { type: integer }
        count: { type: integer, minimum: 1 }

    SubscriptionCreate:
      type: object
      required: [phone_number, billing_period]
      properties:
        phone_number:
          type: string
          pattern: '^8\d{10}$'
          example: '87001234567'
        amount:
          type: number
          minimum: 100
          maximum: 1000000
          description: Required if `cart_items` is omitted (XOR).
        cart_items:
          type: array
          items: { $ref: '#/components/schemas/SubscriptionCartItemInput' }
          description: |
            Catalog references resolved into an immutable snapshot at
            creation time. Future catalog deletions or price updates do
            NOT affect this subscription unless PUT replaces `cart_items`.
        billing_period: { $ref: '#/components/schemas/BillingPeriod' }
        billing_day:
          type: integer
          minimum: 1
          maximum: 28
          description: Required for `monthly` / `quarterly` / `yearly`.
        description: { type: string, maxLength: 255 }
        subscriber_name: { type: string, maxLength: 255 }
        external_subscriber_id:
          type: string
          maxLength: 255
          description: |
            Per-tenant idempotency hint. Collisions against `active` /
            `paused` subscriptions of the same tenant return 409
            `SUBSCRIPTION_EXISTS`. Collisions against `cancelled` /
            `expired` subscriptions do NOT block. Same id across tenants
            does NOT block.
        started_at:
          type: string
          format: date
          description: First billing date (ISO date, no time). Defaults to today.
        max_retry_attempts:
          type: integer
          minimum: 1
          maximum: 10
          default: 3
        retry_interval_hours:
          type: integer
          minimum: 1
          maximum: 168
          default: 24
        grace_period_days:
          type: integer
          minimum: 1
          maximum: 30
          default: 7
        metadata:
          type: object
          description: Arbitrary tenant-side data.
        bill_immediately:
          type: boolean
          default: false
          description: |
            When `true`, sets `next_billing_at = now()` so the next
            scheduler tick (≤60 s) creates the first invoice. The
            response's `next_billing_at` will already be in the past at
            read time.

    SubscriptionUpdate:
      type: object
      description: |
        Mutable subset only. Other fields (e.g. `is_sandbox`, `tenant_id`,
        `phone_number`, `billing_period`, `status`, `next_billing_at`) are
        rejected by the server-side whitelist (defence in depth).
        Disallowed on `cancelled` / `expired` subscriptions.
      properties:
        amount: { type: number, minimum: 100, maximum: 1000000 }
        billing_day: { type: integer, minimum: 1, maximum: 28 }
        description: { type: string, maxLength: 255 }
        subscriber_name: { type: string, maxLength: 255 }
        external_subscriber_id: { type: string, maxLength: 255 }
        max_retry_attempts: { type: integer, minimum: 1, maximum: 10 }
        retry_interval_hours: { type: integer, minimum: 1, maximum: 168 }
        grace_period_days: { type: integer, minimum: 1, maximum: 30 }
        metadata: { type: object }
        cart_items:
          type: array
          items: { $ref: '#/components/schemas/SubscriptionCartItemInput' }

    Subscription:
      type: object
      description: Basic shape returned by POST/PUT/list/pause/resume/cancel.
      properties:
        id: { type: integer, example: 17 }
        phone_number: { type: string }
        amount: { type: string, description: 2-decimal string }
        billing_period: { $ref: '#/components/schemas/BillingPeriod' }
        billing_day: { type: integer, nullable: true }
        status: { $ref: '#/components/schemas/SubscriptionStatus' }
        description: { type: string, nullable: true }
        subscriber_name: { type: string, nullable: true }
        external_subscriber_id: { type: string, nullable: true }
        next_billing_at: { type: string, format: date-time, nullable: true }
        is_sandbox: { type: boolean }
        created_at: { type: string, format: date-time }

    SubscriptionList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Subscription' }
        meta: { $ref: '#/components/schemas/Pagination' }

    SubscriptionDetail:
      type: object
      description: GET /subscriptions/{id} shape — adds stats + last_payment.
      properties:
        subscription:
          allOf:
            - { $ref: '#/components/schemas/Subscription' }
            - type: object
              properties:
                failed_attempts: { type: integer, minimum: 0 }
                in_grace_period: { type: boolean }
                metadata: { type: object }
        stats:
          type: object
          properties:
            total_payments: { type: integer }
            successful_payments: { type: integer }
            failed_payments: { type: integer }
            total_collected: { type: string, description: 2-decimal string }
        last_payment:
          nullable: true
          type: object
          properties:
            amount: { type: string }
            status: { type: string, nullable: true }
            paid_at: { type: string, format: date-time, nullable: true }

    SubscriptionInvoice:
      type: object
      description: |
        Link row exposed via `GET /subscriptions/{id}/invoices`. Joins the
        billing-cycle metadata (period bounds, attempt number, link-row
        status) with the actual invoice that was created by the scheduler.
      properties:
        id:
          type: integer
          description: Link record id (NOT the invoice id).
        invoice_id:
          type: integer
          description: Foreign key into `/api/v1/invoices/{id}`.
        billing_period_start: { type: string, format: date-time }
        billing_period_end: { type: string, format: date-time }
        billing_period_label:
          type: string
          description: 'Human-readable label, e.g. `January 2026` / `Q1 2026` / `2026-01-15`.'
        amount: { type: string, description: 2-decimal string }
        attempt_number:
          type: integer
          minimum: 1
          description: 1 for first attempt; increments on retry within the same period.
        status:
          type: string
          enum: [pending, paid, failed, cancelled, expired]
          description: Link-row status (NOT the canonical invoice status).
        failure_reason:
          type: string
          nullable: true
          description: |
            Short failure description from Kaspi. Digit sequences ≥10
            characters long are pre-redacted server-side to `[REDACTED]`
            to avoid leaking phones / IINs into logs or API responses.
        paid_at: { type: string, format: date-time, nullable: true }
        invoice:
          allOf:
            - { $ref: '#/components/schemas/Invoice' }
          nullable: true
          description: Inline full invoice payload.
        created_at: { type: string, format: date-time }

    Webhook:
      type: object
      properties:
        url: { type: string, format: uri, nullable: true }
        events:
          type: array
          items: { type: string }
          example: [invoice.status_changed]
        enabled: { type: boolean }
        secret_set: { type: boolean }

    WebhookInvoiceStatusChanged:
      type: object
      description: |
        Payload of the single `invoice.status_changed` event delivered as
        an outbound webhook. Signed with HMAC-SHA256 over the raw body
        using the tenant's webhook secret; header is
        `X-Webhook-Signature: sha256=<hex>`. For subscription-driven
        invoices the nested `invoice` object additionally carries a
        `subscription_id` field (absent on one-off invoices — not `null`).
      properties:
        event:
          type: string
          enum: [invoice.status_changed]
        invoice:
          type: object
          properties:
            id: { type: integer }
            external_order_id: { type: string, nullable: true }
            amount: { type: string }
            status: { $ref: '#/components/schemas/InvoiceStatus' }
            client_name: { type: string, nullable: true }
            client_phone: { type: string, nullable: true }
            paid_at: { type: string, format: date-time, nullable: true }
            subscription_id:
              type: integer
              nullable: true
              description: |
                Present only when the invoice was created by the
                subscription scheduler. Drop-in compatibility for
                non-subscription invoices is preserved — for one-off
                invoices this key is omitted from the JSON entirely.
        timestamp: { type: string, format: date-time }

    # ─── Dashboard schemas (internal `/api/dashboard/v1/*`) ────────────────
    SignupRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          maxLength: 254
          example: owner@example.com
        password:
          type: string
          minLength: 8
          maxLength: 256
          example: correcthorse

    SignupResponse:
      type: object
      description: |
        Returned by `POST /api/dashboard/v1/signup`. The `api_key.secret`
        value is shown ONCE — the server keeps only its SHA-256 hash.
      properties:
        user: { type: object }
        tenant: { type: object }
        api_key:
          allOf:
            - { $ref: '#/components/schemas/ApiKey' }
            - type: object
              required: [secret]
              properties:
                secret:
                  type: string
                  description: |
                    Full plaintext API key — shown ONCE. Cache before
                    the post-signup redirect.
                  example: kpa_live_xxxxxxx_<full-secret>
        webhook_secret:
          type: string
          description: HMAC secret used to sign outbound webhooks; shown once.
        csrf_token:
          type: string
          description: |
            Echo of the `csrf` cookie value. The SPA reads either source.

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string }

    PasswordChangeRequest:
      type: object
      required: [current_password, new_password]
      properties:
        current_password: { type: string }
        new_password:
          type: string
          minLength: 8
          maxLength: 256

    ApiKey:
      type: object
      description: |
        Public projection of an API key — never includes the plaintext
        secret. `mode` is derived: `is_sandbox=true` → `test`, else `live`.
      properties:
        id: { type: string, example: key_abc123 }
        name: { type: string, example: Default API key }
        prefix:
          type: string
          example: kpa_live_xxxxxxx
          description: '`kpa_live_*` (production) or `kpa_test_*` (sandbox).'
        mode:
          type: string
          enum: [live, test]
        is_sandbox: { type: boolean }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    ApiKeyCreateRequest:
      type: object
      required: [name, mode]
      properties:
        name:
          type: string
          pattern: '^[A-Za-z0-9 _.-]{1,64}$'
          example: Production cabin
        mode:
          type: string
          enum: [live, test]

    ApiKeyCreateResponse:
      type: object
      description: |
        Returned by `POST /api/dashboard/v1/api-keys` AND
        `POST /api/dashboard/v1/api-keys/{id}/rotate`. The `secret`
        is shown ONCE.
      properties:
        api_key:
          allOf:
            - { $ref: '#/components/schemas/ApiKey' }
            - type: object
              required: [secret]
              properties:
                secret:
                  type: string
                  description: Full plaintext API key — shown ONCE.
                  example: kpa_live_xxxxxxx_<full-secret>

    KaspiSmsRequestBody:
      type: object
      required: [phone_number]
      properties:
        phone_number:
          type: string
          pattern: '^8\d{10}$'
          example: '87001234567'

    KaspiSmsConfirmBody:
      type: object
      required: [code]
      properties:
        code:
          type: string
          example: '123456'

    KaspiStatus:
      type: object
      description: |
        Read-only snapshot returned by `GET /api/dashboard/v1/kaspi/status`
        and embedded in the `POST /kaspi/sms/confirm` response. Never
        carries `tokenSN` or `vtokenSecret`; phone is masked.
      properties:
        connected: { type: boolean }
        connected_at: { type: string, format: date-time, nullable: true }
        profile_id: { type: string, nullable: true }
        phone_masked:
          type: string
          nullable: true
          example: '8XX***4567'

x-webhooks:
  invoice.status_changed:
    post:
      summary: Single canonical event for every invoice status transition
      description: |
        Delivered to the URL configured via `POST /webhook/configure`.
        Headers include `X-Webhook-Signature: sha256=<hex>` (HMAC-SHA256
        over the raw request body using the tenant's `webhook.secret`).
        Verify with `crypto.timingSafeEqual`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookInvoiceStatusChanged' }
      responses:
        '200':
          description: |
            Acknowledge with any 2xx. Non-2xx responses are retried with
            exponential backoff (3 attempts, 0s / 5s / 30s).
