openapi: 3.1.0
info:
  title: Skypay API
  version: 0.1.0
  description: |
    Payment aggregator API. This document currently covers the gateway
    management surface (NEXT-01), the API key surface (NEXT-02), and the payment
    surface (NEXT-03: initiation, status, and sandbox simulation), and the
    hosted checkout surface (NEXT-09). Every JSON
    response is wrapped in the
    standard envelope: `{ success, data }`, `{ success, data, meta }` for
    paginated lists, or `{ success: false, error: { code, message, field? } }`.
servers:
  - url: /api/v1
security: []

tags:
  - name: Admin - Gateways
  - name: Merchant - Checkout
  - name: Merchant - API Keys
  - name: Merchant - Webhooks
  - name: Merchant - Payments
  - name: Payments - Sandbox
  - name: Merchant - Dashboard
  - name: Public
  - name: Public - Hosted Checkout

paths:
  /admin/gateways:
    get:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: List all gateways
      description: |
        Admin-only. Returns every gateway, including disabled and draining ones.
        `businessCount` is the number of active businesses currently using the
        gateway (all active businesses minus explicit opt-outs). Encrypted
        processor credentials are never included.
      responses:
        '200':
          description: The gateway list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/AdminGatewaySummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Create a gateway
      description: |
        Creates a gateway in the disabled state. The built-in SkyPay gateway is
        enabled separately by an admin. The slug `skypay` is reserved.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, name]
              properties:
                slug:
                  type: string
                  pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
                  minLength: 2
                  maxLength: 50
                  example: paystack
                name:
                  type: string
                  minLength: 2
                  maxLength: 100
                  example: Paystack
                description:
                  type: string
                  maxLength: 1000
                displayOrder:
                  type: integer
                  minimum: 0
      responses:
        '201':
          description: The created gateway
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AdminGatewaySummary'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: A gateway with this slug already exists

  /admin/gateways/{id}:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    get:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Get one gateway with its opt-outs
      responses:
        '200':
          description: The gateway detail
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AdminGatewayDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Update gateway presentation
      description: |
        Updates display fields only. The built-in SkyPay gateway may only change
        `displayOrder` — its name and description are fixed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 2
                  maxLength: 100
                description:
                  type: string
                  maxLength: 1000
                  nullable: true
                displayOrder:
                  type: integer
                  minimum: 0
      responses:
        '200':
          description: The updated gateway
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AdminGatewaySummary'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Remove a gateway from the platform
      description: |
        Soft delete. Refused for the built-in SkyPay gateway, and refused while
        the gateway is `ACTIVE` or `DRAINING` — a gateway must finish its drain
        window before it can be removed.
      responses:
        '200':
          description: The gateway was removed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The gateway is built-in, active, or still draining

  /admin/gateways/{id}/enable:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    post:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Enable a gateway for every business
      description: |
        Sets the gateway to `ACTIVE` and rolls it out to every active business
        (opt-out model, ADR-001). Businesses that already opted out keep their
        own setting. Already-enabled gateways return `409`.
      responses:
        '200':
          description: The enabled gateway
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AdminGatewaySummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The gateway is already enabled

  /admin/gateways/{id}/disable:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    post:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Disable a gateway and start its drain window
      description: |
        New routing stops immediately and the gateway moves to `DRAINING` for
        30 minutes before becoming `DISABLED`. The built-in SkyPay gateway can
        never be disabled.
      responses:
        '200':
          description: The draining gateway
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/AdminGatewaySummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The gateway is built-in or is not currently active

  /admin/gateways/{id}/merchants:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    get:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: List businesses using a gateway
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: perPage
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: search
          in: query
          schema:
            type: string
            maxLength: 100
      responses:
        '200':
          description: A page of business gateway records
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/AdminGatewayMerchant'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/gateways/{id}/credentials:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    patch:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Replace stored processor credentials
      description: |
        Credentials are encrypted at rest with AES-256-GCM before being written.
        The submitted object replaces the previous one in full. Credentials are
        never returned by any endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [credentials]
              properties:
                credentials:
                  type: object
                  minProperties: 1
                  maxProperties: 50
                  additionalProperties:
                    type: string
                    maxLength: 4000
                  example:
                    secretKey: sk_live_xxx
                    publicKey: pk_live_xxx
      responses:
        '200':
          description: Credentials were stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/gateways/{id}/logo:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    post:
      tags: [Admin - Gateways]
      security: [{ adminBearerAuth: [] }]
      summary: Upload a gateway logo
      description: |
        Multipart upload. PNG and JPEG only, maximum 2 MB. Replacing a logo with
        a different file extension removes the previous file.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: The stored logo
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          logoUrl:
                            type: string
                            example: /api/v1/public/gateways/clx123/logo
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /merchant/businesses/{businessId}/gateways:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      tags: [Merchant - Checkout]
      security: [{ merchantBearerAuth: [] }]
      summary: List the gateways available to a business
      description: |
        Returns only globally active gateways, with the business's own overrides
        applied. The built-in SkyPay option is always first. A gateway the
        business has never configured reports `isEnabled: true` (opt-out model).
      responses:
        '200':
          description: The business gateway list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/GatewayWithBusinessSettings'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business

  /merchant/businesses/{businessId}/gateways/{gatewayId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - name: gatewayId
        in: path
        required: true
        schema:
          type: string
    patch:
      tags: [Merchant - Checkout]
      security: [{ merchantBearerAuth: [] }]
      summary: Update a business's settings for one gateway
      description: |
        Toggles the option on or off, renames it for this business, or marks it
        as the default. The SkyPay option cannot be disabled and cannot be
        reordered. Disabling an option clears its default flag.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                isEnabled:
                  type: boolean
                displayOrder:
                  type: integer
                  minimum: 0
                displayName:
                  type: string
                  maxLength: 50
                  nullable: true
                isDefault:
                  type: boolean
      responses:
        '200':
          description: The updated gateway settings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/GatewayWithBusinessSettings'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business
        '404':
          $ref: '#/components/responses/NotFound'

  /merchant/businesses/{businessId}/gateways/reorder:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    post:
      tags: [Merchant - Checkout]
      security: [{ merchantBearerAuth: [] }]
      summary: Reorder a business's checkout options
      description: |
        Persists the drag-and-drop order from the checkout settings page. The
        built-in SkyPay entry is ignored — it is always rendered first. Every
        submitted gateway must be globally active.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [gatewayId, displayOrder]
                    properties:
                      gatewayId:
                        type: string
                      displayOrder:
                        type: integer
                        minimum: 0
      responses:
        '200':
          description: The reordered list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/GatewayWithBusinessSettings'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business

  /public/gateways/{id}/logo:
    parameters:
      - $ref: '#/components/parameters/GatewayId'
    get:
      tags: [Public]
      summary: Fetch a gateway logo
      security: []
      description: |
        Public and unauthenticated — logos are shown on hosted checkout pages.
        Responses are cacheable for one hour and sent with
        `X-Content-Type-Options: nosniff`.
      responses:
        '200':
          description: The logo image
          headers:
            Cache-Control:
              schema:
                type: string
                example: public, max-age=3600
            X-Content-Type-Options:
              schema:
                type: string
                const: nosniff
          content:
            image/png: {}
            image/jpeg: {}
        '404':
          $ref: '#/components/responses/NotFound'

  /merchant/businesses/{businessId}/api-keys:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      tags: [Merchant - API Keys]
      security: [{ merchantBearerAuth: [] }]
      summary: List API keys grouped by environment
      description: |
        Returns every key for the business, grouped by environment. Secrets are
        never included: only a masked display value such as `ssk_live_...a3f9`.
        The revoked history is capped at the 20 most recent revoked keys per
        environment; the underlying records are permanent and are never deleted.
      responses:
        '200':
          description: The business key list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BusinessApiKeyGroups'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business, or the business is not active
    post:
      tags: [Merchant - API Keys]
      security: [{ merchantBearerAuth: [] }]
      summary: Generate a new API key pair
      description: |
        Creates a key pair and returns the secret key **once**. It is stored only
        as a bcrypt hash and can never be retrieved again. Sandbox keys are
        available immediately; live keys require `kycStatus: APPROVED`. A maximum
        of 5 active keys per environment is enforced — revoke one first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [environment, name]
              properties:
                environment:
                  type: string
                  enum: [SANDBOX, LIVE]
                name:
                  type: string
                  minLength: 2
                  maxLength: 100
                  example: My website
      responses:
        '201':
          description: The generated key pair, including the one-time secret key
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/GeneratedApiKey'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `KYC_REQUIRED` when a live key is requested before KYC approval, or
            `BUSINESS_RESTRICTED` when the business is not active
        '409':
          description: |
            `KEY_LIMIT_REACHED` — 5 active keys already exist for this
            environment and business

  /merchant/businesses/{businessId}/api-keys/{keyId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/ApiKeyId'
    delete:
      tags: [Merchant - API Keys]
      security: [{ merchantBearerAuth: [] }]
      summary: Revoke an API key
      description: |
        Revocation is permanent — a key can never be reactivated. Requests made
        with the revoked key start failing immediately. The record is retained as
        an audit trail and still appears in the list as `REVOKED`.
      responses:
        '200':
          description: The key was revoked
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: Key revoked. Any integrations using this key will stop working immediately.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The key has already been revoked

  /merchant/businesses/{businessId}/webhooks:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: List webhook endpoints
      description: |
        Returns Sandbox and Live endpoints for the business. Read access remains
        available when a business is restricted or suspended so delivery history
        is not hidden. Secrets are never returned.
      responses:
        '200':
          description: Webhook endpoints for the business
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookEndpoint'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business
    post:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: Create a webhook endpoint
      description: |
        Creates an environment-scoped endpoint and returns its signing secret
        once. Sandbox endpoints receive only Sandbox events and Live endpoints
        receive only Live events. An active business may configure either
        environment before KYC approval; this does not unlock Live payments.
        Restricted and suspended businesses cannot mutate webhook settings.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url, environment]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS is required for Live endpoints.
                description:
                  type: string
                  maxLength: 200
                events:
                  type: array
                  items:
                    type: string
                environment:
                  type: string
                  enum: [SANDBOX, LIVE]
      responses:
        '201':
          description: The endpoint and its one-time signing secret
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The business is restricted or suspended, or the user lacks a manager role
        '409':
          description: The business already has five active webhook endpoints

  /payments/context:
    get:
      tags: [Merchant - Payments]
      security: [{ merchantSecretKey: [] }]
      summary: Resolve the context for a secret API key
      description: |
        The production consumer of API key authentication. Returns the business
        and environment the presented secret key belongs to. Payment endpoints
        added in later tasks use the same middleware.
      responses:
        '200':
          description: The resolved key context
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/ApiKeyContext'
        '401':
          description: |
            Missing, malformed, revoked, or unknown key (`UNAUTHORIZED` when no
            bearer credential was presented, `INVALID_API_KEY` otherwise)
        '403':
          description: |
            `KYC_REQUIRED` for a live key on a business without approved KYC, or
            `BUSINESS_RESTRICTED` for a business that is not active

  /payments/initiate:
    post:
      tags: [Merchant - Payments]
      security: [{ merchantSecretKey: [] }]
      summary: Initiate a payment
      description: |
        Creates the payment reference and an `INITIATED` transaction, then returns
        the checkout URL the payer should be sent to.

        The processor is **not** contacted here. `initiateCharge` runs only when
        the payer submits payment on the hosted checkout page, so this endpoint
        never blocks on a processor and can never half-charge a payment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InitiatePaymentRequest'
      responses:
        '201':
          description: Payment reference created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/InitiatedPayment'
        '200':
          description: |
            Idempotent replay: the same `idempotencyKey` was submitted while the
            original payment is still `INITIATED` or `PENDING`. The original
            transaction is returned unchanged — no second transaction is created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/InitiatedPayment'
        '400':
          description: |
            `VALIDATION_ERROR` (amount not an integer kobo, below ₦1.00, currency
            other than NGN, invalid callback URL, more than 50 metadata keys),
            `GATEWAY_NOT_FOUND` (unknown, inactive, draining, or the sandbox
            processor requested by a live key), or `GATEWAY_DISABLED` (the
            business opted out of that gateway)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `KYC_REQUIRED` for a live key on a business without approved KYC, or
            `BUSINESS_RESTRICTED` for a business that is not active
        '409':
          description: |
            `DUPLICATE_PAYMENT` — this idempotency key was already used for a
            completed payment
        '503':
          description: |
            `NO_GATEWAY_AVAILABLE` — a live payment with no usable gateway.
            Sandbox payments never fail this way.

  /payments/{reference}:
    get:
      tags: [Merchant - Payments]
      security: [{ merchantSecretKey: [] }]
      summary: Get a payment
      description: |
        The merchant-visible view of one payment. The transaction must belong to
        the business *and* the environment of the presented key — a sandbox key
        can never read a live payment, and another business's reference is
        indistinguishable from one that does not exist.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      responses:
        '200':
          description: The payment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PaymentDetail'
        '400':
          description: '`INVALID_REFERENCE` — the reference is not in SKY-YYYYMMDD-XXXXXX form'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: '`PAYMENT_NOT_FOUND` — unknown reference, or one owned by another business or environment'

  /payments/{reference}/simulate:
    post:
      tags: [Payments - Sandbox]
      security: [{ merchantSecretKey: [] }]
      summary: Simulate a payment outcome (sandbox only)
      description: |
        Stands in for a processor webhook, which the sandbox never sends. Moves
        an `INITIATED` or `PENDING` sandbox payment to its final state so a
        merchant can exercise their own webhook and fulfilment handling.

        Always refused for a live key.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulatePaymentRequest'
      responses:
        '200':
          description: The updated payment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PaymentDetail'
        '400':
          description: '`INVALID_REFERENCE` or `VALIDATION_ERROR` on `outcome`'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`SANDBOX_ONLY` — the presented key is a live key'
        '404':
          description: '`PAYMENT_NOT_FOUND`'
        '409':
          description: '`PAYMENT_ALREADY_FINAL` — the payment has already completed'

  /public/payments/{reference}:
    get:
      tags: [Public - Hosted Checkout]
      summary: Load a payment for hosted checkout
      description: |
        Public, unauthenticated endpoint used by the hosted checkout application.
        It returns only payer-safe presentation and status fields. Internal
        identifiers, customer data, processor responses, processor references,
        webhook URLs, and merchant credentials are never returned.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      responses:
        '200':
          description: Payer-safe payment details
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        $ref: '#/components/schemas/PublicPaymentDetail'
        '400':
          description: '`INVALID_REFERENCE` — malformed SkyPay reference'
        '404':
          description: '`PAYMENT_NOT_FOUND` — the reference does not exist'

  /public/payments/{reference}/gateways:
    get:
      tags: [Public - Hosted Checkout]
      summary: List payment options for hosted checkout
      description: |
        Returns active gateways available to the payment's business after its
        gateway overrides are applied. Sandbox includes the explicit SkyPay
        Simulator and provider-backed test options. Unavailable providers remain
        visible with `available: false` so checkout can explain why they cannot
        be selected. SkyPay never silently falls back to the simulator.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      responses:
        '200':
          description: Available checkout gateways
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [gateways]
                        properties:
                          gateways:
                            type: array
                            items:
                              $ref: '#/components/schemas/CheckoutGateway'
        '400':
          description: '`INVALID_REFERENCE`'
        '404':
          description: '`PAYMENT_NOT_FOUND`'

  /public/payments/{reference}/process:
    post:
      tags: [Public - Hosted Checkout]
      summary: Start processing a hosted payment
      description: |
        Persists the payer's gateway choice and calls `initiateCharge` for the
        first time. Paystack returns a redirect URL and Payaza returns SDK launch
        parameters encoded in `checkoutUrl` in both Sandbox and Live. Only the
        explicit `sandbox` gateway uses deterministic local simulation.

        Calling this endpoint never permits the payer to change the amount,
        currency, business, environment, or callback URL.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProcessPublicPaymentRequest'
            examples:
              live:
                value:
                  gatewaySlug: payaza
              sandbox:
                value:
                  gatewaySlug: sandbox
                  cardNumber: '4084084084084081'
              paystackSandbox:
                value:
                  gatewaySlug: paystack
              payazaSandbox:
                value:
                  gatewaySlug: payaza
      responses:
        '200':
          description: Processor hand-off instructions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        $ref: '#/components/schemas/ProcessPublicPaymentResult'
        '400':
          description: |
            `INVALID_REFERENCE`, `VALIDATION_ERROR`, or
            `GATEWAY_NOT_AVAILABLE`. A provider configuration failure may return
            `GATEWAY_ENVIRONMENT_NOT_CONFIGURED`.
        '404':
          description: '`PAYMENT_NOT_FOUND`'
        '409':
          description: '`PAYMENT_ALREADY_PROCESSED` — payment is already terminal'
        '410':
          description: '`PAYMENT_EXPIRED` — the one-hour checkout window elapsed'

  /public/payments/{reference}/verify:
    get:
      tags: [Public - Hosted Checkout]
      summary: Verify a hosted payment
      description: |
        Returns the current transaction status. For the explicit simulator, the
        server applies the deterministic outcome associated with the selected
        test card. For Paystack and Payaza in either environment, the server
        verifies against the assigned provider environment, validates amount and
        currency, persists any status transition, and dispatches the merchant
        webhook asynchronously.

        Browser results and callback query parameters are informational. Merchants
        should confirm payment through their authenticated payment-status endpoint
        or a verified webhook before fulfilling an order.
      parameters:
        - $ref: '#/components/parameters/PaymentReference'
      responses:
        '200':
          description: Current payment status and merchant callback URL
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        $ref: '#/components/schemas/VerifyPublicPaymentResult'
        '400':
          description: '`INVALID_REFERENCE` or `GATEWAY_NOT_AVAILABLE`'
        '404':
          description: '`PAYMENT_NOT_FOUND`'
        '502':
          description: '`PAYMENT_AMOUNT_MISMATCH` — processor details did not match'

  /merchant/businesses/{businessId}/test-payment:
    post:
      tags: [Merchant - Dashboard]
      security: [{ merchantBearerAuth: [] }]
      summary: Create a test payment (dashboard)
      description: |
        The dashboard's test-payment tool. Runs the same payment service as
        `POST /payments/initiate`, server-side, so the browser never handles a
        secret key: the merchant's sandbox key is only checked for existence, and
        raw secret keys are never stored or reconstructed.

        Always creates a SANDBOX payment, served by the sandbox processor.
      parameters:
        - $ref: '#/components/parameters/BusinessId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TestPaymentRequest'
      responses:
        '201':
          description: Test payment created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/InitiatedPayment'
        '200':
          description: Idempotent replay of an open test payment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/InitiatedPayment'
        '400':
          description: |
            `NO_SANDBOX_KEY` (the business has no active sandbox key yet) or
            `VALIDATION_ERROR` on the amount
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business

  /merchant/businesses/{businessId}/test-payment/{reference}:
    get:
      tags: [Merchant - Dashboard]
      security: [{ merchantBearerAuth: [] }]
      summary: Get a test payment (dashboard)
      description: Status of a sandbox test payment, for the modal's verify action.
      parameters:
        - $ref: '#/components/parameters/BusinessId'
        - $ref: '#/components/parameters/PaymentReference'
      responses:
        '200':
          description: The payment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PaymentDetail'
        '400':
          description: '`INVALID_REFERENCE`'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business
        '404':
          description: '`PAYMENT_NOT_FOUND`'

  /merchant/businesses/{businessId}/test-payment/{reference}/simulate:
    post:
      tags: [Merchant - Dashboard]
      security: [{ merchantBearerAuth: [] }]
      summary: Simulate a test payment outcome (dashboard)
      description: Sandbox-only counterpart of `POST /payments/{reference}/simulate`.
      parameters:
        - $ref: '#/components/parameters/BusinessId'
        - $ref: '#/components/parameters/PaymentReference'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulatePaymentRequest'
      responses:
        '200':
          description: The updated payment
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PaymentDetail'
        '400':
          description: '`INVALID_REFERENCE` or `VALIDATION_ERROR` on `outcome`'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The user is not an OWNER or ADMIN of this business
        '404':
          description: '`PAYMENT_NOT_FOUND`'
        '409':
          description: '`PAYMENT_ALREADY_FINAL`'

  /merchant/businesses/{businessId}/webhooks/{endpointId}:
    patch:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: Update webhook endpoint
      responses:
        '200': { description: Updated endpoint }
    delete:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: Deactivate webhook endpoint
      responses:
        '200': { description: Endpoint deactivated }

  /merchant/businesses/{businessId}/webhooks/{endpointId}/test:
    post:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: Send test webhook
      responses:
        '200': { description: Test delivery result }

  /merchant/businesses/{businessId}/webhooks/{endpointId}/deliveries:
    get:
      tags: [Merchant - Webhooks]
      security: [{ merchantBearerAuth: [] }]
      summary: Webhook delivery log
      responses:
        '200': { description: Paginated delivery log without payloads }

  /webhooks/payaza:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Payaza Live webhook (compatibility alias)
      description: Temporary alias for `/webhooks/payaza/live` while provider dashboard configuration is migrated.
      security: []
      responses:
        '200': { description: Event acknowledged }
        '400': { description: Invalid webhook signature }
        '429': { description: Rate limited (100 requests per minute per IP) }

  /webhooks/payaza/sandbox:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Payaza Sandbox webhook
      description: Verifies the raw payload with the Payaza Sandbox signing secret. Events can update only SANDBOX transactions.
      security: []
      responses:
        '200': { description: Event acknowledged, including non-signature processing failures }
        '400': { description: '`INVALID_WEBHOOK_SIGNATURE` or malformed payload' }
        '429': { description: Rate limited (100 requests per minute per IP) }

  /webhooks/payaza/live:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Payaza Live webhook
      description: Verifies the raw payload with the Payaza Live signing secret. Events can update only LIVE transactions.
      security: []
      responses:
        '200': { description: Event acknowledged, including non-signature processing failures }
        '400': { description: '`INVALID_WEBHOOK_SIGNATURE` or malformed payload' }
        '429': { description: Rate limited (100 requests per minute per IP) }

  /webhooks/paystack:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Paystack Live webhook (compatibility alias)
      description: |
        Signature is HMAC-SHA512 (hex) over the raw request body, sent in
        `x-paystack-signature`. It is verified against the configured Live
        environment webhook secret. A missing or mismatched signature is refused
        rather than trusted. This route remains a temporary alias for
        `/webhooks/paystack/live`.
      security: []
      responses:
        '200': { description: Event acknowledged }
        '400': { description: Invalid webhook signature or malformed payload }
        '429': { description: Rate limited (100 requests per minute per IP) }
        '503': { description: Paystack credentials are not configured }

  /webhooks/paystack/sandbox:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Paystack Sandbox webhook
      description: |
        Verifies HMAC-SHA512 over the raw body with the Paystack Sandbox webhook
        secret. A valid event can update only SANDBOX transactions. Duplicate
        deliveries and repeated terminal events are idempotent.
      security: []
      responses:
        '200': { description: Event acknowledged }
        '400': { description: '`INVALID_WEBHOOK_SIGNATURE` or malformed payload' }
        '429': { description: Rate limited (100 requests per minute per IP) }

  /webhooks/paystack/live:
    post:
      tags: [Inbound Webhooks]
      summary: Receive Paystack Live webhook
      description: |
        Verifies HMAC-SHA512 over the raw body with the Paystack Live webhook
        secret. A valid event can update only LIVE transactions. The legacy
        `/webhooks/paystack` path remains a temporary Live alias.
      security: []
      responses:
        '200': { description: Event acknowledged }
        '400': { description: '`INVALID_WEBHOOK_SIGNATURE` or malformed payload' }
        '429': { description: Rate limited (100 requests per minute per IP) }

components:
  securitySchemes:
    adminBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Admin token carrying `{ sub, type: 'admin', role }`.
    merchantBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Merchant token carrying `{ sub, type: 'merchant', emailVerified }`. The
        active business is client-side state, so business-scoped routes always
        take a `businessId` path parameter rather than reading it from the token.
    merchantSecretKey:
      type: http
      scheme: bearer
      description: |
        A merchant secret API key (`ssk_sand_...` or `ssk_live_...`). The key
        itself selects the environment — there is a single API URL for both. The
        key is sent as `Authorization: Bearer <secret key>`; public keys
        (`spk_...`) are never accepted as credentials. Sandbox keys work as soon
        as the business exists; live keys require `kycStatus: APPROVED`, and keys
        attached to a business that is not `ACTIVE` are rejected.

  parameters:
    GatewayId:
      name: id
      in: path
      required: true
      schema:
        type: string
    BusinessId:
      name: businessId
      in: path
      required: true
      schema:
        type: string
    ApiKeyId:
      name: keyId
      in: path
      required: true
      schema:
        type: string
    PaymentReference:
      name: reference
      in: path
      required: true
      description: A SkyPay payment reference, `SKY-YYYYMMDD-XXXXXX`.
      schema:
        type: string
        pattern: '^SKY-\d{8}-[A-Z0-9]{6}$'
        example: SKY-20241215-A7X9K2

  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    Forbidden:
      description: The token is valid but lacks the required realm or role
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: The resource does not exist
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ValidationError:
      description: The request failed validation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: VALIDATION_ERROR
              message: Slug can only contain lowercase letters and hyphens
              field: slug

  schemas:
    SuccessEnvelope:
      type: object
      properties:
        success:
          type: boolean
          const: true
    ErrorEnvelope:
      type: object
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            field:
              type: string
    PaginationMeta:
      type: object
      properties:
        page:
          type: integer
        perPage:
          type: integer
        total:
          type: integer
    GatewayStatus:
      type: string
      enum: [ACTIVE, DRAINING, DISABLED]
      description: |
        `ACTIVE` accepts new checkouts, `DRAINING` rejects new checkouts while
        in-flight ones settle, `DISABLED` is fully off.
    AdminGatewaySummary:
      type: object
      description: Never contains processor credentials.
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
        description:
          type: string
          nullable: true
        logoPath:
          type: string
          nullable: true
        logoUrl:
          type: string
          nullable: true
        isActive:
          type: boolean
        status:
          $ref: '#/components/schemas/GatewayStatus'
        displayOrder:
          type: integer
        isBuiltIn:
          type: boolean
        drainStartedAt:
          type: string
          format: date-time
          nullable: true
        drainEndsAt:
          type: string
          format: date-time
          nullable: true
        businessCount:
          type: integer
          description: Active businesses using the gateway (opt-outs excluded).
        hasCredentials:
          type: boolean
          description: Whether encrypted credentials are on file.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    AdminGatewayDetail:
      allOf:
        - $ref: '#/components/schemas/AdminGatewaySummary'
        - type: object
          properties:
            overrides:
              type: array
              description: Businesses that turned this gateway off.
              items:
                type: object
                properties:
                  businessId:
                    type: string
                  businessName:
                    type: string
                  updatedAt:
                    type: string
                    format: date-time
    AdminGatewayMerchant:
      type: object
      properties:
        id:
          type: string
        businessId:
          type: string
        businessName:
          type: string
        isEnabled:
          type: boolean
        displayName:
          type: string
          nullable: true
        displayOrder:
          type: integer
        isDefault:
          type: boolean
        createdAt:
          type: string
          format: date-time
    GatewayWithBusinessSettings:
      type: object
      description: A gateway as one business sees it, with its overrides applied.
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
        description:
          type: string
          nullable: true
        logoPath:
          type: string
          nullable: true
        logoUrl:
          type: string
          nullable: true
        displayOrder:
          type: integer
        isBuiltIn:
          type: boolean
        isEnabled:
          type: boolean
        businessDisplayOrder:
          type: integer
          nullable: true
        displayName:
          type: string
          nullable: true
        isDefault:
          type: boolean

    ApiKeyRecord:
      type: object
      description: A key as shown to the merchant. Secret material is never included.
      properties:
        id:
          type: string
        name:
          type: string
        publicKey:
          type: string
          example: spk_live_9f2c1a7b4d8e3f5a6b0c1d2e
        secretKeyDisplay:
          type: string
          description: Masked secret. The raw value is only ever returned at generation time.
          example: ssk_live_...a3f9
        status:
          type: string
          enum: [ACTIVE, REVOKED]
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
        revokedAt:
          type: string
          format: date-time
          nullable: true

    BusinessApiKeyGroups:
      type: object
      properties:
        sandbox:
          type: array
          items:
            $ref: '#/components/schemas/ApiKeyRecord'
        live:
          type: array
          items:
            $ref: '#/components/schemas/ApiKeyRecord'

    GeneratedApiKey:
      type: object
      description: The only response that ever contains a raw `secretKey`.
      properties:
        id:
          type: string
        name:
          type: string
        publicKey:
          type: string
        secretKey:
          type: string
          example: ssk_sand_1f0c9b8a7d6e5f4a3b2c1d0e
        environment:
          type: string
          enum: [SANDBOX, LIVE]
        createdAt:
          type: string
          format: date-time
        secretKeyNote:
          type: string
          example: Store this key safely. It will never be shown again.

    ApiKeyContext:
      type: object
      properties:
        businessId:
          type: string
        businessName:
          type: string
        environment:
          type: string
          enum: [SANDBOX, LIVE]
        kycStatus:
          type: string
          enum: [NOT_SUBMITTED, PENDING, UNDER_REVIEW, APPROVED, REJECTED]

    WebhookEndpoint:
      type: object
      required:
        - id
        - url
        - environment
        - secretLast4
        - isActive
        - events
        - createdAt
        - deliverySummary
      properties:
        id:
          type: string
        url:
          type: string
          format: uri
        description:
          type: [string, 'null']
        environment:
          type: string
          enum: [SANDBOX, LIVE]
          description: Only events from this environment are delivered.
        secretLast4:
          type: string
        isActive:
          type: boolean
        events:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time
        deliverySummary:
          type: object
          required: [successful, failed]
          properties:
            successful:
              type: integer
            failed:
              type: integer
    TransactionStatus:
      type: string
      enum: [INITIATED, PENDING, SUCCESS, FAILED, REVERSED]
      description: |
        `INITIATED` — reference created, payer not yet redirected.
        `PENDING` — payer is on the checkout page or the processor is working.
        `SUCCESS` / `FAILED` / `REVERSED` — final states.

    InitiatedPayment:
      type: object
      properties:
        reference:
          type: string
          example: SKY-20241215-A7X9K2
        checkoutUrl:
          type: string
          description: Where to send the payer. Expires with the reference.
          example: https://pay.skypay.ng/SKY-20241215-A7X9K2
        amount:
          type: integer
          description: Integer kobo, echoed back exactly as requested.
          example: 50000
        currency:
          type: string
          example: NGN
        environment:
          type: string
          enum: [SANDBOX, LIVE]
        status:
          $ref: '#/components/schemas/TransactionStatus'
        expiresAt:
          type: string
          format: date-time
          description: One hour after initiation.

    PaymentDetail:
      type: object
      description: |
        The merchant-visible view of a payment. `processorResponse`,
        `processorReference`, `webhookUrl` and `businessId` are internal and are
        never returned by any endpoint.
      properties:
        reference:
          type: string
        amount:
          type: integer
          description: Integer kobo.
        currency:
          type: string
        status:
          $ref: '#/components/schemas/TransactionStatus'
        gateway:
          type: string
          nullable: true
          description: Denormalized gateway slug.
        customerEmail:
          type: string
          nullable: true
        customerName:
          type: string
          nullable: true
        environment:
          type: string
          enum: [SANDBOX, LIVE]
        initiatedAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
        failureReason:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
          additionalProperties: true
        checkoutUrl:
          type: string

    PublicPaymentDetail:
      type: object
      description: Payer-safe payment fields used by hosted checkout.
      required:
        - reference
        - amount
        - currency
        - status
        - businessName
        - environment
        - expiresAt
        - isExpired
        - callbackUrl
        - failureReason
      properties:
        reference:
          type: string
          example: SKY-20241215-A7X9K2
        amount:
          type: integer
          description: Integer kobo. Display clients convert this to naira.
          example: 50000
        currency:
          type: string
          example: NGN
        status:
          $ref: '#/components/schemas/TransactionStatus'
        businessName:
          type: string
          example: Example Stores Ltd
        environment:
          type: string
          enum: [SANDBOX, LIVE]
        expiresAt:
          type: string
          format: date-time
        isExpired:
          type: boolean
        callbackUrl:
          type: [string, 'null']
          format: uri
        failureReason:
          type: [string, 'null']

    CheckoutGateway:
      type: object
      required: [id, slug, name, logoUrl, isDefault, isBrand, available, unavailableReason]
      properties:
        id:
          type: string
        slug:
          type: string
          example: payaza
        name:
          type: string
          example: Payaza
        logoUrl:
          type: [string, 'null']
          format: uri
        isDefault:
          type: boolean
        isBrand:
          type: boolean
          description: Whether this is the platform-branded routing option.
        available:
          type: boolean
          description: Whether this option may be selected for the payment environment.
        unavailableReason:
          type: [string, 'null']
          description: Payer-safe reason shown when the provider cannot be selected.

    ProcessPublicPaymentRequest:
      type: object
      additionalProperties: false
      required: [gatewaySlug]
      properties:
        gatewaySlug:
          type: string
          minLength: 1
        cardNumber:
          type: string
          minLength: 12
          maxLength: 24
          description: SkyPay Simulator only. Provider card data is collected by the provider-hosted journey.

    ProcessPublicPaymentResult:
      type: object
      required: [processorReference, checkoutUrl, gateway, method]
      properties:
        processorReference:
          type: string
          description: Processor correlation reference for this attempt.
        checkoutUrl:
          type: [string, 'null']
          format: uri
          description: Redirect destination or URL containing SDK launch parameters.
        gateway:
          type: string
          description: Final processor gateway slug persisted on the transaction.
        method:
          type: string
          enum: [redirect, sdk, simulate]

    VerifyPublicPaymentResult:
      type: object
      required: [status, callbackUrl]
      properties:
        status:
          $ref: '#/components/schemas/TransactionStatus'
        callbackUrl:
          type: [string, 'null']
          format: uri

    InitiatePaymentRequest:
      type: object
      required: [amount, callbackUrl]
      properties:
        amount:
          type: integer
          minimum: 100
          description: Integer kobo. Minimum ₦1.00 (100).
        currency:
          type: string
          enum: [NGN]
          default: NGN
        customerEmail:
          type: string
          format: email
        customerName:
          type: string
          maxLength: 100
        customerPhone:
          type: string
        gateway:
          type: string
          description: |
            Optional gateway slug. Overrides routing: explicit → business default
            → first available gateway. The `skypay` brand slug resolves through
            routing; a sandbox key always ends up on the sandbox processor.
        callbackUrl:
          type: string
          format: uri
          description: Where the payer is sent after the payment.
        metadata:
          type: object
          maxProperties: 50
          additionalProperties:
            type: [string, number]
        idempotencyKey:
          type: string
          maxLength: 100
          description: |
            Deduplication key, unique per business and environment. Reusing it
            while the payment is open returns that payment (200); reusing it
            after the payment finished returns `DUPLICATE_PAYMENT` (409).

    TestPaymentRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: integer
          minimum: 100
        currency:
          type: string
          enum: [NGN]
        customerEmail:
          type: string
          format: email
        customerName:
          type: string
          maxLength: 100

    SimulatePaymentRequest:
      type: object
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [success, failed, insufficient_funds]
