openapi: "3.0.3"

info:
  title: "PayElect Payment API"
  version: "1.0.0"
  description: |
    ## Overview

    The PayElect Payment API enables merchants to collect payments, issue refunds, send
    payouts, and manage subaccounts programmatically. It supports both a **Sandbox**
    environment for integration testing and a **Production** environment for live
    transactions.

    All requests must be made over HTTPS. Requests made over plain HTTP will be rejected
    with a `400 Bad Request`.

    ## Authentication

    Most endpoints require a short-lived **Bearer access token** obtained from
    `POST /authentication/token`. Tokens expire after **600 seconds** (10 minutes).
    The token is scoped to a single payment session — issue a new one for each payment
    flow.

    ## Request / Response Format

    All request bodies must be sent as `application/json`. Every response follows the
    unified envelope below.

    **Success:**
    ```json
    {
      "success": true,
      "data": { "...": "..." },
      "error": null,
      "meta": {
        "request_id": "01JXXXXXXXXXX",
        "timestamp": "2026-07-12T10:00:00Z"
      }
    }
    ```

    **Error:**
    ```json
    {
      "success": false,
      "data": null,
      "error": {
        "code": "PAY_1001",
        "message": "Human-readable message",
        "details": {}
      },
      "meta": {
        "request_id": "01JXXXXXXXXXX",
        "timestamp": "2026-07-12T10:00:00Z"
      }
    }
    ```

    The `meta.request_id` is a ULID generated per request and included in every
    response. Include it when contacting support.

    ## Error Code Scheme

    | Range      | Domain                        |
    |------------|-------------------------------|
    | PAY_1xxx   | Authentication / credential   |
    | PAY_2xxx   | Payment                       |
    | PAY_3xxx   | Refund                        |
    | PAY_4xxx   | Payout                        |
    | PAY_5xxx   | Webhook                       |
    | PAY_6xxx   | Subaccount                    |
    | PAY_7xxx   | Validation / input            |
    | PAY_8xxx   | Provider / gateway            |
    | PAY_9xxx   | Rate limit / quota            |

    ## Idempotency

    `POST /payment/create` and `POST /payout` accept an idempotency key. Submitting the
    same key a second time within the token's lifetime returns the original response
    without creating a duplicate record.

    ## Webhook Signatures

    Outbound webhooks (PayElect → your server) are signed with HMAC-SHA256. The
    `Payelecthq-Signature` header value has the format:

    ```
    t=<unix_timestamp>,v1=<hex_signature>
    ```

    Signed string: `"<unix_timestamp>.<raw_json_body>"`. Recompute the signature and
    compare with constant-time equality to validate authenticity.

    Inbound provider webhooks (provider → PayElect) are internal — your integration
    does not need to handle them.

    ## Versioning Policy

    - The current API version is **v1**.
    - Breaking changes will be released under a new version path (e.g., `/v2/`).
    - When v2 ships, v1 will receive a **90-day deprecation notice** and will be
      supported for a **minimum of 12 months** after the v2 general-availability date.
    - Additive changes (new optional request fields, new response fields, new error
      codes in existing ranges) are non-breaking and may be made at any time.

    ## Sandbox

    The sandbox environment is functionally identical to production but no real funds
    are moved. Sandbox credentials (issued in mode `SANDBOX`) only work against the
    sandbox base URL and vice-versa.

    **Hardcoded sandbox payer:**
    - Email: `sandbox@appdevs.net`
    - Username: `appdevs`
    - Available balance: `100,000` (in the requested currency)

    All sandbox payments succeed immediately with zero fees.

  contact:
    name: "PayElect Developer Support"
    url: "https://payelecthq.com/developer/docs"
    email: "api-support@payelecthq.com"
  license:
    name: "Proprietary — BereTEK LLC"
    url: "https://payelecthq.com/terms"

externalDocs:
  description: "Full developer documentation and guides"
  url: "https://payelecthq.com/developer/docs"

servers:
  - url: "https://api.payelecthq.com/pay/api/v1"
    description: "Production"
  - url: "https://api.payelecthq.com/pay/sandbox/api/v1"
    description: "Sandbox — no real funds, safe for integration testing"

tags:
  - name: "Authentication"
    description: |
      Obtain short-lived access tokens. Tokens are scoped per credential pair and
      expire after 600 seconds. Generate a fresh token at the start of every payment
      session.
  - name: "Payments"
    description: |
      Create payment sessions, retrieve available gateways for a country/currency/amount
      combination, and poll payment status.
  - name: "Refunds"
    description: |
      Initiate full or partial refunds on completed payments and retrieve refund details.
  - name: "Payouts"
    description: |
      Disburse funds to mobile money wallets, bank accounts, or platform wallets.
  - name: "Subaccounts"
    description: |
      Create and manage subaccounts for marketplace split payments. Each subaccount
      undergoes a KYB (Know Your Business) review before it can receive settled funds.

# ---------------------------------------------------------------------------
# Security Schemes
# ---------------------------------------------------------------------------
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Short-lived access token issued by `POST /authentication/token`.
        Pass in the `Authorization: Bearer <token>` header.
        Token lifetime: **600 seconds**.

  # ---------------------------------------------------------------------------
  # Reusable Schemas
  # ---------------------------------------------------------------------------
  schemas:

    # ── Envelope ──────────────────────────────────────────────────────────────

    ResponseMeta:
      type: object
      description: "Metadata present on every response."
      required:
        - request_id
        - timestamp
      properties:
        request_id:
          type: string
          description: "ULID generated per request. Quote this in support tickets."
          example: "01JXXXXXXXXXX"
        timestamp:
          type: string
          format: date-time
          description: "UTC timestamp at which the response was generated (ISO 8601)."
          example: "2026-07-12T10:00:00Z"

    ApiError:
      type: object
      description: "Structured error object. Present only when `success` is false."
      required:
        - code
        - message
      properties:
        code:
          type: string
          description: "Machine-readable error code (PAY_xxxx scheme)."
          example: "PAY_1002"
        message:
          type: string
          description: "Human-readable error message suitable for display."
          example: "The provided credentials are invalid."
        details:
          type: object
          description: "Additional structured context (e.g., per-field validation errors)."
          additionalProperties: true
          example:
            amount: ["The amount field must be greater than 0."]

    ApiResponse:
      type: object
      description: "Unified response envelope used by every endpoint."
      required:
        - success
        - data
        - error
        - meta
      properties:
        success:
          type: boolean
          description: "`true` on success, `false` on error."
        data:
          description: "Response payload. `null` on error."
          nullable: true
        error:
          description: "Error details. `null` on success."
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ApiError"
        meta:
          $ref: "#/components/schemas/ResponseMeta"

    # ── Authentication ─────────────────────────────────────────────────────────

    TokenRequest:
      type: object
      description: "Credentials used to obtain an access token."
      required:
        - client_id
        - client_secret
      properties:
        client_id:
          type: string
          description: >
            The `client_id` value from your PayElect developer credentials.
            Obtain this from the merchant dashboard under **Developer → API Keys**.
          example: "live_cid_a1b2c3d4e5f6"
        client_secret:
          type: string
          description: >
            The corresponding `client_secret`. Treat this as a password — never
            expose it in client-side code or version control.
          example: "live_secret_xxxxxxxxxxxxxxxx"

    TokenData:
      type: object
      description: "Access token payload returned on successful authentication."
      required:
        - token
        - expires_at
        - mode
      properties:
        token:
          type: string
          description: "The opaque Bearer access token. Use in `Authorization: Bearer <token>`."
          example: "eyJhbGciOiJub25lIn0..."
        expires_at:
          type: string
          format: date-time
          description: "UTC expiry time (ISO 8601). The token is invalid after this moment."
          example: "2026-07-12T10:10:00Z"
        mode:
          type: string
          enum: ["SANDBOX", "PRODUCTION"]
          description: "Operating mode of the credential used to generate this token."
          example: "PRODUCTION"

    TokenResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/TokenData"

    # ── Gateways ───────────────────────────────────────────────────────────────

    Gateway:
      type: object
      description: "A single payment gateway/provider available for the requested parameters."
      required:
        - slug
        - name
        - currencies
        - min_amount
        - max_amount
      properties:
        slug:
          type: string
          description: >
            Machine-readable gateway identifier. Pass this as the `gateway` field in
            `POST /payment/create` to pre-select a provider.
          example: "cinetpay"
        name:
          type: string
          description: "Display name of the gateway."
          example: "CinetPay"
        logo_url:
          type: string
          format: uri
          nullable: true
          description: "HTTPS URL of the gateway logo (PNG, 200×60 px recommended)."
          example: "https://api.payelecthq.com/storage/gateways/cinetpay.png"
        currencies:
          type: array
          description: "ISO 4217 currency codes accepted by this gateway."
          items:
            type: string
          example: ["GNF", "XOF", "USD"]
        min_amount:
          type: number
          format: double
          description: "Minimum single-transaction amount in the requested currency."
          example: 1000
        max_amount:
          type: number
          format: double
          description: "Maximum single-transaction amount in the requested currency."
          example: 10000000

    GatewayListData:
      type: object
      required:
        - gateways
      properties:
        gateways:
          type: array
          items:
            $ref: "#/components/schemas/Gateway"

    GatewayListResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/GatewayListData"

    # ── Payments ───────────────────────────────────────────────────────────────

    SplitEntry:
      type: object
      description: "A single split allocation within a marketplace payment."
      required:
        - subaccount_uid
        - amount
      properties:
        subaccount_uid:
          type: string
          description: "ULID of the destination subaccount."
          example: "01JXXXXXXXXXXXXXXXXX"
        amount:
          type: number
          format: double
          description: >
            Amount to allocate to this subaccount. Cannot exceed the payment amount
            minus the platform fee. The sum of all split amounts must not exceed
            `amount` minus fees.
          example: 15000

    PaymentCreateRequest:
      type: object
      description: "Payload to initiate a payment session."
      required:
        - amount
        - currency
        - trx_id
        - return_url
      properties:
        amount:
          type: number
          format: double
          description: >
            Positive payment amount. Maximum 2 decimal places (0 decimals for GNF
            and XOF per ISO 4217 rules).
          example: 50000
        currency:
          type: string
          description: >
            ISO 4217 currency code. Must match a currency enabled in your merchant
            settings. Supported values include `GNF`, `XOF`, `USD`.
          minLength: 3
          maxLength: 3
          example: "GNF"
        trx_id:
          type: string
          description: >
            Merchant-supplied idempotency key. Unique per payment intent. If a
            payment with this key already exists, the existing session is returned
            unchanged without creating a duplicate. Maximum 100 characters.
          maxLength: 100
          example: "order_2026_07_12_00042"
        customer_email:
          type: string
          format: email
          nullable: true
          description: "Payer's email address. Included in webhook payloads and receipts."
          example: "customer@example.com"
        description:
          type: string
          nullable: true
          description: "Human-readable payment description shown to the payer. Maximum 500 characters."
          maxLength: 500
          example: "Subscription renewal — July 2026"
        return_url:
          type: string
          format: uri
          description: >
            HTTPS URL to redirect the payer after the payment flow completes
            (success, failure, or cancellation). Query parameters appended by
            PayElect include `type`, `message`, and `data`.
          example: "https://mystore.example.com/checkout/return"
        cancel_url:
          type: string
          format: uri
          description: >
            HTTPS URL to redirect the payer if they cancel before completing
            the payment. Defaults to `return_url` if omitted.
          nullable: true
          example: "https://mystore.example.com/checkout/cancel"
        webhook_url:
          type: string
          format: uri
          nullable: true
          description: >
            Per-session HTTPS webhook override. When set, PayElect sends events for
            this payment to this URL instead of (or in addition to) the registered
            webhook endpoint.
          example: "https://mystore.example.com/webhooks/payelecthq"
        metadata:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: >
            Arbitrary key-value pairs stored with the payment and echoed back in
            webhook payloads. Maximum 50 keys. Values must be strings.
          example:
            orderId: "ORD-9823"
            customerId: "USR-1042"
        gateway:
          type: string
          nullable: true
          description: >
            Specific gateway slug to pre-select (e.g., `cinetpay`, `orange_money`,
            `wave`, `mtn_momo`, `flutterwave`, `paystack`). If omitted, the payer
            chooses on the hosted payment page. If the gateway is unavailable, the
            cascade engine selects the next best option.
          example: "orange_money"
        country:
          type: string
          nullable: true
          description: >
            ISO 3166-1 alpha-2 country code of the payer. Used to filter the
            available gateways. When omitted, PayElect infers the country from
            the currency.
          minLength: 2
          maxLength: 2
          example: "GN"
        split:
          type: array
          nullable: true
          description: >
            Marketplace split allocations. When provided, the net payment amount
            (gross minus platform fee) is distributed across the listed subaccounts.
            Subaccounts must be active and KYB-approved before splits are executed.
          items:
            $ref: "#/components/schemas/SplitEntry"
          maxItems: 20

    PaymentCreateData:
      type: object
      required:
        - trx_id
        - payment_url
        - expires_at
        - token
      properties:
        trx_id:
          type: string
          description: "The merchant-supplied `trx_id` echoed back for correlation."
          example: "order_2026_07_12_00042"
        payment_url:
          type: string
          format: uri
          description: >
            Hosted payment page URL. Redirect the payer's browser here to complete
            the payment. Valid until `expires_at`.
          example: "https://api.payelecthq.com/pay/v1/user/authentication/form/abc123"
        expires_at:
          type: string
          format: date-time
          description: "UTC expiry of the payment session (ISO 8601). Typically 10 minutes from creation."
          example: "2026-07-12T10:10:00Z"
        token:
          type: string
          description: "Internal session token used by the hosted payment page. Treat as opaque."
          example: "abc123xyz"

    PaymentCreateResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/PaymentCreateData"

    PaymentStatus:
      type: string
      enum: ["PENDING", "SUCCESS", "FAILED", "CANCELLED", "EXPIRED"]
      description: |
        Current lifecycle state of a payment order.
        - `PENDING` — created or awaiting provider confirmation
        - `SUCCESS` — payment confirmed and merchant wallet credited
        - `FAILED` — payment was declined or could not be processed
        - `CANCELLED` — payer cancelled before completing
        - `EXPIRED` — the payment session window elapsed without completion

    PaymentStatusData:
      type: object
      required:
        - trx_id
        - status
        - amount
        - currency
        - created_at
        - updated_at
      properties:
        trx_id:
          type: string
          description: "Merchant-supplied transaction ID."
          example: "order_2026_07_12_00042"
        status:
          $ref: "#/components/schemas/PaymentStatus"
        amount:
          type: number
          format: double
          description: "Requested payment amount."
          example: 50000
        currency:
          type: string
          description: "ISO 4217 currency code."
          example: "GNF"
        gateway:
          type: string
          nullable: true
          description: "Slug of the gateway used to complete the payment."
          example: "cinetpay"
        provider_reference:
          type: string
          nullable: true
          description: "Provider-side transaction reference. Useful for reconciliation."
          example: "CINET-TX-20260712-0042"
        customer_email:
          type: string
          format: email
          nullable: true
          description: "Payer email address if provided at creation."
          example: "customer@example.com"
        metadata:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: "Key-value metadata supplied at payment creation."
          example:
            orderId: "ORD-9823"
        created_at:
          type: string
          format: date-time
          description: "UTC creation timestamp (ISO 8601)."
          example: "2026-07-12T10:00:00Z"
        updated_at:
          type: string
          format: date-time
          description: "UTC last-update timestamp (ISO 8601)."
          example: "2026-07-12T10:02:15Z"

    PaymentStatusResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/PaymentStatusData"

    # ── Refunds ────────────────────────────────────────────────────────────────

    RefundCreateRequest:
      type: object
      description: "Payload to initiate a refund on a completed payment."
      properties:
        amount:
          type: number
          format: double
          nullable: true
          description: >
            Amount to refund. Must be greater than zero and less than or equal to
            the refundable balance of the payment. When omitted, a full refund is
            issued.
          example: 25000
        reason:
          type: string
          nullable: true
          description: "Human-readable reason for the refund (max 500 characters)."
          maxLength: 500
          example: "Customer requested cancellation"

    RefundStatus:
      type: string
      enum: ["PENDING", "SUCCESS", "FAILED"]
      description: |
        - `PENDING` — refund request submitted to the provider
        - `SUCCESS` — funds returned to the payer
        - `FAILED` — provider rejected the refund

    RefundData:
      type: object
      required:
        - refundUid
        - trxId
        - amount
        - currency
        - status
        - createdAt
      properties:
        refundUid:
          type: string
          description: "Unique ULID identifier for this refund record."
          example: "01JREFUNDXXXXXXXXXX"
        trxId:
          type: string
          description: "The `trx_id` of the original payment."
          example: "order_2026_07_12_00042"
        amount:
          type: number
          format: double
          description: "Refunded amount."
          example: 25000
        currency:
          type: string
          description: "ISO 4217 currency code of the refund."
          example: "GNF"
        status:
          $ref: "#/components/schemas/RefundStatus"
        reason:
          type: string
          nullable: true
          description: "Reason supplied at refund creation."
          example: "Customer requested cancellation"
        initiatedBy:
          type: string
          nullable: true
          description: "Actor who initiated the refund (`merchant` or `admin`)."
          example: "merchant"
        providerSlug:
          type: string
          nullable: true
          description: "Gateway slug used to process the refund."
          example: "cinetpay"
        providerReference:
          type: string
          nullable: true
          description: "Provider-side refund reference."
          example: "CINET-REF-20260712-001"
        failureReason:
          type: string
          nullable: true
          description: "Human-readable reason if `status` is `FAILED`."
          example: null
        processedAt:
          type: string
          format: date-time
          nullable: true
          description: "UTC timestamp when the provider confirmed the refund (ISO 8601)."
          example: "2026-07-12T10:05:30Z"
        createdAt:
          type: string
          format: date-time
          description: "UTC creation timestamp (ISO 8601)."
          example: "2026-07-12T10:03:00Z"

    RefundResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/RefundData"

    # ── Payouts ────────────────────────────────────────────────────────────────

    PayoutRecipientType:
      type: string
      enum: ["MOBILE_MONEY", "BANK", "WALLET"]
      description: |
        Destination type for the payout.
        - `MOBILE_MONEY` — disburse to a mobile money account (phone number required)
        - `BANK` — disburse via bank transfer (account number and bank name required)
        - `WALLET` — transfer to another PayElect wallet

    PayoutRecipient:
      type: object
      description: "Destination details for the payout."
      required:
        - type
        - account_name
      properties:
        type:
          $ref: "#/components/schemas/PayoutRecipientType"
        phone:
          type: string
          nullable: true
          description: >
            Recipient's mobile number in E.164 format. Required when `type` is
            `MOBILE_MONEY`.
          example: "+224620000000"
        iban:
          type: string
          nullable: true
          description: >
            International Bank Account Number. Required when `type` is `BANK` and the
            destination country uses IBAN.
          example: "GB29NWBK60161331926819"
        account_no:
          type: string
          nullable: true
          description: >
            Local account number. Required when `type` is `BANK` and the destination
            country does not use IBAN.
          example: "0012345678"
        bank_name:
          type: string
          nullable: true
          description: "Name of the destination bank. Required when `type` is `BANK`."
          example: "Ecobank Guinée"
        account_name:
          type: string
          description: "Full legal name of the account holder."
          example: "Mamadou Bah"

    PayoutCreateRequest:
      type: object
      description: "Payload to initiate a payout."
      required:
        - amount
        - currency
        - recipient
        - uid
      properties:
        amount:
          type: number
          format: double
          description: "Positive payout amount. Must not exceed available merchant balance."
          example: 100000
        currency:
          type: string
          description: "ISO 4217 currency code."
          minLength: 3
          maxLength: 3
          example: "GNF"
        recipient:
          $ref: "#/components/schemas/PayoutRecipient"
        description:
          type: string
          nullable: true
          description: "Optional description or memo for the payout (max 500 characters)."
          maxLength: 500
          example: "Commission payment — June 2026"
        metadata:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: "Arbitrary key-value metadata stored with the payout."
          example:
            agentId: "AGT-001"
        uid:
          type: string
          description: >
            Merchant-supplied idempotency key. If a payout with this key already
            exists, the existing record is returned. Maximum 64 characters.
          maxLength: 64
          example: "payout_2026_07_12_00001"

    PayoutStatus:
      type: string
      enum: ["PENDING", "PENDING_APPROVAL", "PROCESSING", "SUCCESS", "FAILED"]
      description: |
        - `PENDING` — submitted, awaiting provider pick-up
        - `PENDING_APPROVAL` — requires manual admin approval (e.g., large amount)
        - `PROCESSING` — provider has accepted and is executing the transfer
        - `SUCCESS` — funds delivered to recipient
        - `FAILED` — payout rejected or timed out

    PayoutData:
      type: object
      required:
        - payoutUid
        - amount
        - currency
        - destinationType
        - status
        - createdAt
      properties:
        payoutUid:
          type: string
          description: "Unique ULID identifier for this payout."
          example: "01JPAYOUTXXXXXXXXX"
        amount:
          type: number
          format: double
          description: "Payout amount."
          example: 100000
        currency:
          type: string
          description: "ISO 4217 currency code."
          example: "GNF"
        destinationType:
          $ref: "#/components/schemas/PayoutRecipientType"
        recipient:
          $ref: "#/components/schemas/PayoutRecipient"
        status:
          $ref: "#/components/schemas/PayoutStatus"
        providerSlug:
          type: string
          nullable: true
          description: "Gateway slug used to execute the payout."
          example: "orange_money"
        providerReference:
          type: string
          nullable: true
          description: "Provider-side reference for the transfer."
          example: "OM-PAYOUT-20260712"
        failureReason:
          type: string
          nullable: true
          description: "Human-readable reason if `status` is `FAILED`."
          example: null
        source:
          type: string
          nullable: true
          description: "Origination source of the payout (`api`, `dashboard`, `auto_settlement`)."
          example: "api"
        mode:
          type: string
          enum: ["sandbox", "production"]
          description: "Environment in which the payout was created."
          example: "production"
        processedAt:
          type: string
          format: date-time
          nullable: true
          description: "UTC timestamp when the provider confirmed delivery (ISO 8601)."
          example: "2026-07-12T11:00:00Z"
        createdAt:
          type: string
          format: date-time
          description: "UTC creation timestamp (ISO 8601)."
          example: "2026-07-12T10:50:00Z"

    PayoutResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/PayoutData"

    # ── Subaccounts ────────────────────────────────────────────────────────────

    SubaccountSettlementDetails:
      type: object
      description: "Banking / mobile money details used for settling subaccount funds."
      required:
        - bank_name
        - account_no
        - account_name
      properties:
        bank_name:
          type: string
          description: "Name of the receiving bank or mobile money operator."
          example: "Ecobank Guinée"
        account_no:
          type: string
          description: "Local account number or mobile money number."
          example: "0012345678"
        account_name:
          type: string
          description: "Full legal name of the account holder."
          example: "Diallo Enterprises SARL"
        iban:
          type: string
          nullable: true
          description: "IBAN, if applicable."
          example: null
        swift:
          type: string
          nullable: true
          description: "BIC/SWIFT code, if applicable."
          example: null

    SubaccountKybStatus:
      type: string
      enum: ["PENDING", "APPROVED", "REJECTED"]
      description: |
        KYB review state.
        - `PENDING` — under review; splits will be escrowed until approved
        - `APPROVED` — verified; splits are disbursed on schedule
        - `REJECTED` — verification failed; contact support

    SubaccountStatus:
      type: string
      enum: ["ACTIVE", "INACTIVE"]
      description: "Operational status of the subaccount."

    SubaccountCreateRequest:
      type: object
      description: "Payload to create a new subaccount."
      required:
        - name
        - split_type
        - split_value
        - settlement_details
      properties:
        name:
          type: string
          description: "Unique display name for the subaccount within your merchant account."
          maxLength: 255
          example: "North Region Vendor"
        split_type:
          type: string
          enum: ["PERCENTAGE", "FLAT"]
          description: |
            How the split amount is calculated.
            - `PERCENTAGE` — `split_value` is a percentage of the net payment (0–100)
            - `FLAT` — `split_value` is a fixed amount in the payment currency
          example: "PERCENTAGE"
        split_value:
          type: number
          format: double
          description: >
            The split amount. For `PERCENTAGE`, must be between 0 and 100 exclusive.
            For `FLAT`, must be greater than zero and less than the payment amount
            minus fees.
          example: 15
        settlement_details:
          $ref: "#/components/schemas/SubaccountSettlementDetails"

    SubaccountUpdateRequest:
      type: object
      description: "Partial update payload for a subaccount. All fields are optional."
      properties:
        name:
          type: string
          maxLength: 255
          description: "New display name. Must be unique within the merchant account."
          example: "North Region Vendor (Updated)"
        split_type:
          type: string
          enum: ["PERCENTAGE", "FLAT"]
          description: "New split calculation method."
          example: "FLAT"
        split_value:
          type: number
          format: double
          description: "New split value."
          example: 5000
        settlement_details:
          $ref: "#/components/schemas/SubaccountSettlementDetails"

    SubaccountData:
      type: object
      required:
        - uid
        - name
        - split_type
        - split_value
        - status
        - created_at
      properties:
        uid:
          type: string
          description: "Unique ULID identifier for the subaccount. Use this in split arrays."
          example: "01JSUBACCTXXXXXXXXX"
        name:
          type: string
          description: "Display name of the subaccount."
          example: "North Region Vendor"
        split_type:
          type: string
          enum: ["PERCENTAGE", "FLAT"]
          description: "Split calculation method."
          example: "PERCENTAGE"
        split_value:
          type: number
          format: double
          description: "Split amount or percentage."
          example: 15
        kyb_status:
          $ref: "#/components/schemas/SubaccountKybStatus"
        status:
          $ref: "#/components/schemas/SubaccountStatus"
        settlement_details:
          $ref: "#/components/schemas/SubaccountSettlementDetails"
        balance:
          type: number
          format: double
          nullable: true
          description: "Current ledger balance in `balance_currency`. Populated on GET /{uid} only."
          example: 450000
        balance_currency:
          type: string
          nullable: true
          description: "Currency of `balance`. Populated on GET /{uid} only."
          example: "GNF"
        created_at:
          type: string
          format: date-time
          description: "UTC creation timestamp (ISO 8601)."
          example: "2026-06-01T08:00:00Z"

    SubaccountResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              type: object
              properties:
                subaccount:
                  $ref: "#/components/schemas/SubaccountData"

    SubaccountListData:
      type: object
      required:
        - subaccounts
      properties:
        subaccounts:
          type: array
          items:
            $ref: "#/components/schemas/SubaccountData"

    SubaccountListResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/SubaccountListData"

    SubaccountDeleteData:
      type: object
      required:
        - message
      properties:
        message:
          type: string
          example: "Subaccount deleted"

    SubaccountDeleteResponse:
      allOf:
        - $ref: "#/components/schemas/ApiResponse"
        - type: object
          properties:
            data:
              $ref: "#/components/schemas/SubaccountDeleteData"

    # ── Webhook event payloads (outbound — PayElect → merchant server) ─────────

    WebhookPayload:
      type: object
      description: |
        Structure of outbound webhook events delivered to your registered endpoint(s).

        Verify authenticity before processing: check the `Payelecthq-Signature` header
        using HMAC-SHA256 with your webhook secret.

        **Signature header format:**  `t=<unix_ts>,v1=<hex_signature>`

        **Signed string:** `"<unix_ts>.<raw_json_body>"`

        Respond with HTTP 200 within 30 seconds. Failed deliveries are retried with
        exponential back-off (5 attempts over 24 hours). After all retries are exhausted,
        the event is marked as permanently failed — no further retries occur.
      required:
        - api_version
        - event_id
        - event_type
        - created_at
        - data
      properties:
        api_version:
          type: string
          description: "API version string in YYYY-MM-DD format."
          example: "2026-07-12"
        event_id:
          type: string
          description: "ULID uniquely identifying this event delivery."
          example: "01JEVTXXXXXXXXXXXXXXXXX"
        event_type:
          type: string
          description: "Dot-notation event type."
          enum:
            - "payment.created"
            - "payment.pending"
            - "payment.succeeded"
            - "payment.failed"
            - "payment.cancelled"
            - "payment.expired"
            - "refund.succeeded"
            - "refund.failed"
            - "payout.created"
            - "payout.succeeded"
            - "payout.failed"
            - "split.settled"
            - "subaccount_payout.created"
            - "subaccount_payout.succeeded"
            - "subaccount_payout.failed"
            - "dispute.created"
          example: "payment.succeeded"
        created_at:
          type: string
          format: date-time
          description: "UTC timestamp when the event was generated (ISO 8601)."
          example: "2026-07-12T10:02:15Z"
        data:
          type: object
          description: "Event-specific resource payload."
          additionalProperties: true

  # ---------------------------------------------------------------------------
  # Reusable Responses
  # ---------------------------------------------------------------------------
  responses:

    Unauthorized:
      description: "Missing or invalid Bearer token (PAY_1001 / PAY_1002)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiResponse"
          example:
            success: false
            data: null
            error:
              code: "PAY_1001"
              message: "Access denied. No token provided."
              details: {}
            meta:
              request_id: "01JXXXXXXXXXX"
              timestamp: "2026-07-12T10:00:00Z"

    Forbidden:
      description: "Token is valid but the credential is inactive or mode-mismatched (PAY_1005)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiResponse"
          example:
            success: false
            data: null
            error:
              code: "PAY_1005"
              message: "This credential is inactive."
              details: {}
            meta:
              request_id: "01JXXXXXXXXXX"
              timestamp: "2026-07-12T10:00:00Z"

    UnprocessableEntity:
      description: "Validation failed (PAY_7001)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiResponse"
          example:
            success: false
            data: null
            error:
              code: "PAY_7001"
              message: "Validation failed."
              details:
                amount: ["The amount field must be greater than 0."]
            meta:
              request_id: "01JXXXXXXXXXX"
              timestamp: "2026-07-12T10:00:00Z"

    RateLimited:
      description: "Rate limit exceeded (PAY_9001)."
      headers:
        Retry-After:
          description: "Seconds to wait before retrying."
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiResponse"
          example:
            success: false
            data: null
            error:
              code: "PAY_9001"
              message: "Too many requests. Retry after the indicated interval."
              details:
                retry_after_seconds: 15
            meta:
              request_id: "01JXXXXXXXXXX"
              timestamp: "2026-07-12T10:00:00Z"

  # ---------------------------------------------------------------------------
  # Reusable Parameters
  # ---------------------------------------------------------------------------
  parameters:

    TrxIdPath:
      name: trx_id
      in: path
      required: true
      description: "Merchant-supplied transaction ID (the `trx_id` used at payment creation)."
      schema:
        type: string
      example: "order_2026_07_12_00042"

    RefundUidPath:
      name: refund_uid
      in: path
      required: true
      description: "Unique ULID of the refund record."
      schema:
        type: string
      example: "01JREFUNDXXXXXXXXXX"

    PayoutUidPath:
      name: payout_uid
      in: path
      required: true
      description: "Unique ULID of the payout record."
      schema:
        type: string
      example: "01JPAYOUTXXXXXXXXX"

    SubaccountUidPath:
      name: uid
      in: path
      required: true
      description: "Unique ULID of the subaccount."
      schema:
        type: string
      example: "01JSUBACCTXXXXXXXXX"

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
paths:

  # ── Authentication ─────────────────────────────────────────────────────────

  /authentication/token:
    post:
      operationId: "generateToken"
      summary: "Obtain a Bearer access token"
      description: |
        Exchange a `client_id` / `client_secret` credential pair for a short-lived
        Bearer access token. The token expires after **600 seconds**.

        - Sandbox credentials only work against the sandbox base URL.
        - Production credentials only work against the production base URL.

        Tokens are **single-use per payment session**. Do not cache tokens across
        multiple payment flows.

        **Rate limit:** 10 requests per minute per credential pair.
      tags:
        - "Authentication"
      x-rate-limit: "10 requests per minute per credential"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenRequest"
            example:
              client_id: "live_cid_a1b2c3d4e5f6"
              client_secret: "live_secret_xxxxxxxxxxxxxxxx"
      responses:
        "200":
          description: "Token issued successfully."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
              example:
                success: true
                data:
                  token: "eyJhbGciOiJub25lIn0..."
                  expires_at: "2026-07-12T10:10:00Z"
                  mode: "PRODUCTION"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          description: "Invalid credentials (PAY_1002)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_1002"
                  message: "The provided credentials are invalid."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "403":
          description: "Credential exists but is inactive or mode-mismatched (PAY_1005)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_1005"
                  message: "This credential is inactive. Contact support."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── Payments ───────────────────────────────────────────────────────────────

  /payments/gateways:
    get:
      operationId: "listGateways"
      summary: "List available payment gateways"
      description: |
        Returns the payment gateways enabled for the merchant and applicable to the
        specified country, currency, and amount. Use the returned `slug` values to
        pre-select a gateway when calling `POST /payment/create`.

        The list is filtered in real-time:
        - Only gateways enabled in the merchant's dashboard are returned.
        - Gateways whose `min_amount` or `max_amount` constraints exclude the
          requested `amount` are omitted.
        - Gateways not available in the requested `country` are omitted.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Payments"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - name: country
          in: query
          required: true
          description: "ISO 3166-1 alpha-2 country code of the payer."
          schema:
            type: string
            minLength: 2
            maxLength: 2
          example: "GN"
        - name: currency
          in: query
          required: true
          description: "ISO 4217 currency code."
          schema:
            type: string
            minLength: 3
            maxLength: 10
          example: "GNF"
        - name: amount
          in: query
          required: true
          description: "Payment amount used to filter gateways by their min/max constraints."
          schema:
            type: number
            format: double
            exclusiveMinimum: 0
          example: 50000
      responses:
        "200":
          description: "Gateway list returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GatewayListResponse"
              example:
                success: true
                data:
                  gateways:
                    - slug: "cinetpay"
                      name: "CinetPay"
                      logo_url: "https://api.payelecthq.com/storage/gateways/cinetpay.png"
                      currencies: ["GNF", "XOF", "USD"]
                      min_amount: 100
                      max_amount: 10000000
                    - slug: "orange_money"
                      name: "Orange Money"
                      logo_url: "https://api.payelecthq.com/storage/gateways/orange_money.png"
                      currencies: ["GNF", "XOF"]
                      min_amount: 1000
                      max_amount: 5000000
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "400":
          description: "Invalid query parameters."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_7001"
                  message: "Validation failed."
                  details:
                    country: ["The country field must be 2 characters."]
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /payment/create:
    post:
      operationId: "createPayment"
      summary: "Create a payment session"
      description: |
        Initialises a new payment order and returns a hosted payment page URL to
        which you redirect the payer's browser. The session expires after **600 seconds**.

        **Flow:**
        1. `POST /authentication/token` → obtain `token`
        2. `POST /payment/create` (Bearer `token`) → obtain `payment_url`
        3. Redirect the payer to `payment_url`
        4. PayElect redirects payer back to `return_url` with result query params
        5. Listen on your registered webhook URL for confirmation events

        **Idempotency:** Providing the same `trx_id` a second time within the token
        lifetime returns the existing session without creating a duplicate (HTTP 200).

        **Split payments:** Include the `split` array to distribute the net proceeds
        among pre-registered, KYB-approved subaccounts. Splits are validated at
        creation time (conservative, zero-fee pass) and again at confirmation with
        the actual fee applied.

        **Rate limit:** 30 requests per minute per credential.
      tags:
        - "Payments"
      x-rate-limit: "30 requests per minute per credential"
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentCreateRequest"
            example:
              amount: 50000
              currency: "GNF"
              trx_id: "order_2026_07_12_00042"
              customer_email: "customer@example.com"
              description: "Subscription renewal — July 2026"
              return_url: "https://mystore.example.com/checkout/return"
              cancel_url: "https://mystore.example.com/checkout/cancel"
              webhook_url: "https://mystore.example.com/webhooks/payelecthq"
              metadata:
                orderId: "ORD-9823"
                customerId: "USR-1042"
              gateway: "orange_money"
              country: "GN"
      responses:
        "201":
          description: "Payment session created. Redirect payer to `data.payment_url`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentCreateResponse"
              example:
                success: true
                data:
                  trx_id: "order_2026_07_12_00042"
                  payment_url: "https://api.payelecthq.com/pay/v1/user/authentication/form/abc123"
                  expires_at: "2026-07-12T10:10:00Z"
                  token: "abc123xyz"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: |
            Conflict — one of:
            - `PAY_2002`: A payment with a different `trx_id` is already in progress for this token.
            - `PAY_7004`: Idempotency key conflict — same `trx_id` used with different parameters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_2002"
                  message: "A payment is already in progress for this session token."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "422":
          description: |
            Validation or business rule error — one of:
            - `PAY_7001`: Input validation failed.
            - `PAY_2003`: Amount below gateway minimum.
            - `PAY_2004`: Amount exceeds gateway maximum.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_7001"
                  message: "Validation failed."
                  details:
                    return_url: ["The return url field must be a valid URL."]
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  /payment/status/{trx_id}:
    get:
      operationId: "getPaymentStatus"
      summary: "Get payment status"
      description: |
        Returns the current status of a payment order without mutating any state.
        Poll this endpoint if you need to reconcile a payment whose webhook was missed
        or if the payer's browser redirect did not reach your `return_url`.

        The response is scoped to the merchant associated with the Bearer token — you
        cannot query payment orders belonging to other merchants.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Payments"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/TrxIdPath"
      responses:
        "200":
          description: "Payment status returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentStatusResponse"
              example:
                success: true
                data:
                  trx_id: "order_2026_07_12_00042"
                  status: "SUCCESS"
                  amount: 50000
                  currency: "GNF"
                  gateway: "cinetpay"
                  provider_reference: "CINET-TX-20260712-0042"
                  customer_email: "customer@example.com"
                  metadata:
                    orderId: "ORD-9823"
                  created_at: "2026-07-12T10:00:00Z"
                  updated_at: "2026-07-12T10:02:15Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:02:20Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Payment order not found for this merchant (PAY_2001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_2001"
                  message: "Payment order not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── Refunds ────────────────────────────────────────────────────────────────

  /payment/{trx_id}/refund:
    post:
      operationId: "createRefund"
      summary: "Initiate a refund"
      description: |
        Request a full or partial refund on a completed (`SUCCESS`) payment. Partial
        refunds can be issued multiple times as long as the cumulative refund amount
        does not exceed the original payment amount.

        Refunds are processed asynchronously. The initial response returns a record
        with `status: PENDING`. Listen for `refund.succeeded` or `refund.failed`
        webhook events for final confirmation.

        **Rate limit:** 30 requests per minute per credential.
      tags:
        - "Refunds"
      x-rate-limit: "30 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/TrxIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefundCreateRequest"
            example:
              amount: 25000
              reason: "Customer requested cancellation"
      responses:
        "201":
          description: "Refund initiated. Monitor `refundUid` via webhooks or `GET /refund/{refund_uid}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RefundResponse"
              example:
                success: true
                data:
                  refundUid: "01JREFUNDXXXXXXXXXX"
                  trxId: "order_2026_07_12_00042"
                  amount: 25000
                  currency: "GNF"
                  status: "PENDING"
                  reason: "Customer requested cancellation"
                  initiatedBy: "merchant"
                  providerSlug: "cinetpay"
                  providerReference: null
                  failureReason: null
                  processedAt: null
                  createdAt: "2026-07-12T10:10:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:10:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Payment order not found for this merchant (PAY_2001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_2001"
                  message: "Payment order not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "409":
          description: "A refund for this payment is already pending or completed (PAY_3005)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_3005"
                  message: "A refund already exists for this payment."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "422":
          description: |
            Business rule error — one of:
            - `PAY_3004`: Payment is not in a refundable state (not `SUCCESS`).
            - `PAY_3002`: Refund amount exceeds the refundable balance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              examples:
                notRefundable:
                  summary: "Payment not refundable"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_3004"
                      message: "Only completed payments can be refunded."
                      details: {}
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
                amountExceeds:
                  summary: "Refund amount too large"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_3002"
                      message: "Refund amount exceeds the refundable balance."
                      details:
                        refundable_balance: 50000
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  /refund/{refund_uid}:
    get:
      operationId: "getRefund"
      summary: "Get refund details"
      description: |
        Retrieve the current state of a refund by its ULID. Use this to poll for
        confirmation if your webhook is temporarily unavailable.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Refunds"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/RefundUidPath"
      responses:
        "200":
          description: "Refund details returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RefundResponse"
              example:
                success: true
                data:
                  refundUid: "01JREFUNDXXXXXXXXXX"
                  trxId: "order_2026_07_12_00042"
                  amount: 25000
                  currency: "GNF"
                  status: "SUCCESS"
                  reason: "Customer requested cancellation"
                  initiatedBy: "merchant"
                  providerSlug: "cinetpay"
                  providerReference: "CINET-REF-20260712-001"
                  failureReason: null
                  processedAt: "2026-07-12T10:15:00Z"
                  createdAt: "2026-07-12T10:10:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:15:05Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Refund not found for this merchant (PAY_3001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_3001"
                  message: "Refund not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── Payouts ────────────────────────────────────────────────────────────────

  /payout:
    post:
      operationId: "createPayout"
      summary: "Initiate a payout"
      description: |
        Disburse funds from your merchant wallet to a mobile money account, bank
        account, or another PayElect wallet.

        **Balance check:** The request will be rejected if your merchant wallet balance
        in the requested `currency` is insufficient to cover the `amount`.

        **Idempotency:** The `uid` field acts as an idempotency key. Resubmitting with
        the same `uid` returns the existing payout record without creating a duplicate.

        **Large payouts** that exceed the configured approval threshold return HTTP 202
        with `status: PENDING_APPROVAL`. An admin must approve them in the dashboard
        before funds are disbursed.

        Payouts are processed asynchronously. Listen for `payout.succeeded` or
        `payout.failed` webhook events for final confirmation.

        **Rate limit:** 30 requests per minute per credential.
      tags:
        - "Payouts"
      x-rate-limit: "30 requests per minute per credential"
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PayoutCreateRequest"
            example:
              amount: 100000
              currency: "GNF"
              recipient:
                type: "MOBILE_MONEY"
                phone: "+224620000000"
                account_name: "Mamadou Bah"
              description: "Commission payment — June 2026"
              metadata:
                agentId: "AGT-001"
              uid: "payout_2026_07_12_00001"
      responses:
        "201":
          description: "Payout initiated. Monitor via webhooks or `GET /payout/{payout_uid}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayoutResponse"
              example:
                success: true
                data:
                  payoutUid: "01JPAYOUTXXXXXXXXX"
                  amount: 100000
                  currency: "GNF"
                  destinationType: "MOBILE_MONEY"
                  recipient:
                    type: "MOBILE_MONEY"
                    phone: "+224620000000"
                    account_name: "Mamadou Bah"
                    iban: null
                    account_no: null
                    bank_name: null
                  status: "PENDING"
                  providerSlug: null
                  providerReference: null
                  failureReason: null
                  source: "api"
                  mode: "production"
                  processedAt: null
                  createdAt: "2026-07-12T10:50:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:50:00Z"
        "202":
          description: "Payout accepted but pending admin approval (PAY_4006)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayoutResponse"
              example:
                success: true
                data:
                  payoutUid: "01JPAYOUTXXXXXXXXX"
                  amount: 5000000
                  currency: "GNF"
                  destinationType: "BANK"
                  status: "PENDING_APPROVAL"
                  source: "api"
                  mode: "production"
                  createdAt: "2026-07-12T10:50:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:50:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: |
            Business rule error — one of:
            - `PAY_4002`: Insufficient wallet balance.
            - `PAY_4003`: Amount below payout minimum for this currency/provider.
            - `PAY_4004`: Amount exceeds payout limit for this currency/provider.
            - `PAY_4005`: Invalid or unresolvable recipient details.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              examples:
                insufficientBalance:
                  summary: "Insufficient balance"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_4002"
                      message: "Insufficient wallet balance for this payout."
                      details:
                        available: 30000
                        requested: 100000
                        currency: "GNF"
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
                invalidRecipient:
                  summary: "Invalid recipient"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_4005"
                      message: "Recipient phone number is invalid for mobile money."
                      details:
                        field: "recipient.phone"
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  /payout/{payout_uid}:
    get:
      operationId: "getPayout"
      summary: "Get payout details"
      description: |
        Retrieve the current state of a payout by its ULID.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Payouts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/PayoutUidPath"
      responses:
        "200":
          description: "Payout details returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayoutResponse"
              example:
                success: true
                data:
                  payoutUid: "01JPAYOUTXXXXXXXXX"
                  amount: 100000
                  currency: "GNF"
                  destinationType: "MOBILE_MONEY"
                  recipient:
                    type: "MOBILE_MONEY"
                    phone: "+224620000000"
                    account_name: "Mamadou Bah"
                    iban: null
                    account_no: null
                    bank_name: null
                  status: "SUCCESS"
                  providerSlug: "orange_money"
                  providerReference: "OM-PAYOUT-20260712"
                  failureReason: null
                  source: "api"
                  mode: "production"
                  processedAt: "2026-07-12T11:00:00Z"
                  createdAt: "2026-07-12T10:50:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T11:00:05Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Payout not found for this merchant (PAY_4001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_4001"
                  message: "Payout not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── Subaccounts ────────────────────────────────────────────────────────────

  /subaccounts:
    get:
      operationId: "listSubaccounts"
      summary: "List subaccounts"
      description: |
        Returns all subaccounts for the authenticated merchant, ordered by creation
        date descending. The `balance` field is omitted on list responses — use
        `GET /subaccounts/{uid}` to retrieve the balance for a specific subaccount.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Subaccounts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      responses:
        "200":
          description: "Subaccount list returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubaccountListResponse"
              example:
                success: true
                data:
                  subaccounts:
                    - uid: "01JSUBACCTXXXXXXXXX"
                      name: "North Region Vendor"
                      split_type: "PERCENTAGE"
                      split_value: 15
                      kyb_status: "APPROVED"
                      status: "ACTIVE"
                      settlement_details:
                        bank_name: "Ecobank Guinée"
                        account_no: "0012345678"
                        account_name: "Diallo Enterprises SARL"
                        iban: null
                        swift: null
                      balance: null
                      balance_currency: null
                      created_at: "2026-06-01T08:00:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      operationId: "createSubaccount"
      summary: "Create a subaccount"
      description: |
        Create a new subaccount for marketplace split payments. The subaccount is
        created in `kyb_status: PENDING` and `status: INACTIVE`. PayElect's compliance
        team will review the `settlement_details` and notify you by email when KYB
        is approved.

        Until KYB is approved, split allocations to this subaccount will be held in
        escrow. Funds are released automatically upon approval.

        **Limit:** A merchant may have at most 50 active subaccounts. Creating a 51st
        returns `PAY_6004`.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Subaccounts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubaccountCreateRequest"
            example:
              name: "North Region Vendor"
              split_type: "PERCENTAGE"
              split_value: 15
              settlement_details:
                bank_name: "Ecobank Guinée"
                account_no: "0012345678"
                account_name: "Diallo Enterprises SARL"
                iban: null
                swift: null
      responses:
        "201":
          description: "Subaccount created. Pending KYB review."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubaccountResponse"
              example:
                success: true
                data:
                  subaccount:
                    uid: "01JSUBACCTXXXXXXXXX"
                    name: "North Region Vendor"
                    split_type: "PERCENTAGE"
                    split_value: 15
                    kyb_status: "PENDING"
                    status: "INACTIVE"
                    settlement_details:
                      bank_name: "Ecobank Guinée"
                      account_no: "0012345678"
                      account_name: "Diallo Enterprises SARL"
                      iban: null
                      swift: null
                    balance: null
                    balance_currency: null
                    created_at: "2026-07-12T10:00:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: "A subaccount with this name already exists (PAY_6002)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_6002"
                  message: "A subaccount with this name already exists."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "422":
          description: |
            Validation or limit error — one of:
            - `PAY_6003`: Invalid `split_type` / `split_value` combination.
            - `PAY_6004`: Subaccount limit (50) reached.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              examples:
                invalidSplit:
                  summary: "Invalid split configuration"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_6003"
                      message: "split_value must be between 0 and 100 for PERCENTAGE type."
                      details:
                        split_type: "PERCENTAGE"
                        split_value: 150
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
                limitReached:
                  summary: "Subaccount limit reached"
                  value:
                    success: false
                    data: null
                    error:
                      code: "PAY_6004"
                      message: "Maximum of 50 subaccounts reached."
                      details: {}
                    meta:
                      request_id: "01JXXXXXXXXXX"
                      timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  /subaccounts/{uid}:
    get:
      operationId: "getSubaccount"
      summary: "Get a subaccount"
      description: |
        Retrieve full details and current ledger balance for a single subaccount.
        Pass the `currency` query parameter to specify the balance currency
        (defaults to `GNF`).

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Subaccounts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/SubaccountUidPath"
        - name: currency
          in: query
          required: false
          description: "ISO 4217 currency code for the balance field. Defaults to `GNF`."
          schema:
            type: string
            default: "GNF"
          example: "GNF"
      responses:
        "200":
          description: "Subaccount details returned."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubaccountResponse"
              example:
                success: true
                data:
                  subaccount:
                    uid: "01JSUBACCTXXXXXXXXX"
                    name: "North Region Vendor"
                    split_type: "PERCENTAGE"
                    split_value: 15
                    kyb_status: "APPROVED"
                    status: "ACTIVE"
                    settlement_details:
                      bank_name: "Ecobank Guinée"
                      account_no: "0012345678"
                      account_name: "Diallo Enterprises SARL"
                      iban: null
                      swift: null
                    balance: 450000
                    balance_currency: "GNF"
                    created_at: "2026-06-01T08:00:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Subaccount not found for this merchant (PAY_6001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_6001"
                  message: "Subaccount not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

    put:
      operationId: "updateSubaccount"
      summary: "Update a subaccount"
      description: |
        Partially update a subaccount's name, split configuration, or settlement
        details. Only fields provided in the request body are updated; omitted fields
        retain their current values.

        Updating `settlement_details` on an approved subaccount will trigger a new
        KYB review cycle. The subaccount reverts to `kyb_status: PENDING` until
        the review is complete.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Subaccounts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/SubaccountUidPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubaccountUpdateRequest"
            example:
              name: "North Region Vendor (Updated)"
              split_type: "FLAT"
              split_value: 5000
      responses:
        "200":
          description: "Subaccount updated."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubaccountResponse"
              example:
                success: true
                data:
                  subaccount:
                    uid: "01JSUBACCTXXXXXXXXX"
                    name: "North Region Vendor (Updated)"
                    split_type: "FLAT"
                    split_value: 5000
                    kyb_status: "APPROVED"
                    status: "ACTIVE"
                    settlement_details:
                      bank_name: "Ecobank Guinée"
                      account_no: "0012345678"
                      account_name: "Diallo Enterprises SARL"
                      iban: null
                      swift: null
                    balance: null
                    balance_currency: null
                    created_at: "2026-06-01T08:00:00Z"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:05:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Subaccount not found (PAY_6001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_6001"
                  message: "Subaccount not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "422":
          description: "Validation error (PAY_6003 — invalid split configuration)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_6003"
                  message: "split_value must be greater than 0 for FLAT type."
                  details:
                    split_value: ["Must be greater than 0."]
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

    delete:
      operationId: "deleteSubaccount"
      summary: "Delete (deactivate) a subaccount"
      description: |
        Soft-deactivates a subaccount. The subaccount and its ledger history are
        retained for audit purposes; no financial records are deleted.

        Deactivated subaccounts cannot receive new split allocations. Any pending
        escrowed split funds will be returned to the merchant wallet.

        **Rate limit:** 100 requests per minute per credential.
      tags:
        - "Subaccounts"
      x-rate-limit: "100 requests per minute per credential"
      security:
        - BearerAuth: []
      parameters:
        - $ref: "#/components/parameters/SubaccountUidPath"
      responses:
        "200":
          description: "Subaccount deactivated."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubaccountDeleteResponse"
              example:
                success: true
                data:
                  message: "Subaccount deleted"
                error: null
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:10:00Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: "Subaccount not found (PAY_6001)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiResponse"
              example:
                success: false
                data: null
                error:
                  code: "PAY_6001"
                  message: "Subaccount not found."
                  details: {}
                meta:
                  request_id: "01JXXXXXXXXXX"
                  timestamp: "2026-07-12T10:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"
