openapi: 3.1.0
info:
  title: iPayMoney Merchant API
  version: 1.0.0
  description: >-
    Contrats marchands v1 vérifiés contre les routes, contrôleurs et services
    Rails. Les contrats partenaires et les routes v2 non opérationnelles ne
    font pas partie de cette spécification publique. Les liens de paiement
    doivent utiliser des références aléatoires privées.
servers:
  - url: https://i-pay.money/api/v1
    description: Sandbox ou Live selon les en-têtes et la clé utilisée
tags:
  - name: Payments
    description: Création et suivi des transactions marchandes
  - name: Payment links
    description: Liens de paiement hébergés
paths:
  /payments:
    get:
      tags: [Payments]
      summary: Lister les transactions
      description: >-
        Pagination côté serveur : le nombre d'éléments par page se règle avec
        le paramètre `items` (25 par défaut). La réponse reste un tableau JSON
        brut, sans aucune métadonnée de pagination structurée : ni enveloppe
        `meta`, ni en-tête `Link` ou `Total-Count`. Le paramètre de page n'est
        pas documenté par ce contrat, et l'API ne renvoie ni total ni lien de
        page suivante permettant de le construire côté client.
      x-verified: >-
        Note interne de vérification (non destinée à la documentation
        publique). Vérifié le 2026-09-16 dans `Api::V1::PaymentsController#index`
        (app/controllers/api/v1/payments_controller.rb:4-16) : aucun appel à
        `pagy_headers_merge` ni à `pagy_metadata` nulle part dans le dépôt
        Rails (recherche exhaustive), et
        `app/views/api/v1/payments/index.json.jbuilder:1` rend directement
        `json.array! @payments` sans enveloppe `meta` ni en-tête `Link` /
        `Total-Count`. Les extras Pagy `headers` et `metadata` sont chargés
        dans `config/initializers/pagy.rb` mais ne sont branchés sur aucune
        route de ce dépôt : la capacité existe côté gem, pas dans ce
        contrôleur. Pagy utilise son nom de paramètre de page par défaut
        (`page`, non surchargé dans l'initializer).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ContentType'
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
        - name: query
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema:
            type: string
            enum: [initiated, pending, succeeded, failed, refunded]
        - name: created_from
          in: query
          schema: { type: string, format: date }
        - name: created_to
          in: query
          schema: { type: string, format: date }
        - name: items
          in: query
          schema: { type: integer, default: 25, minimum: 1 }
      responses:
        '200':
          description: >-
            Liste des transactions (tableau JSON brut, sans enveloppe de
            pagination). Les valeurs de `status` et de `payment_method` y sont
            formatées pour l'affichage (ex. `Succeeded`, `Airtel Ne`) et non
            normalisées : ne pas les comparer aux valeurs de statut utilisées
            par les autres opérations.
          x-verified: >-
            Note interne de vérification (non destinée à la documentation
            publique). Formatage rendu par `payment.displayed_status` et
            `payment.displayed_payment_method` — voir
            app/views/api/v1/payments/_payment.json.jbuilder:3,6.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PaymentListItem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
    post:
      tags: [Payments]
      summary: Créer une transaction
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentRequest' }
            examples:
              sandboxMobile:
                summary: Transaction mobile Sandbox réussie
                value:
                  amount: 1000
                  country: BJ
                  currency: XOF
                  msisdn: '40410000000'
                  transaction_id: ORDER-SANDBOX-001
                  customer_name: Client Test
      responses:
        '200':
          description: Transaction enregistrée. Le statut final doit être vérifié séparément.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentCreateResponse' }
        '400':
          description: >-
            En-tête ou paramètre d'authentification invalide, ou corps de la
            requête invalide (champ manquant ou valeur hors contrainte).
          x-verified: >-
            Note interne de vérification (non destinée à la documentation
            publique). Détecté par `ApiAuthService#authenticate`
            (app/services/api_auth_service.rb:20-26) ou
            `Live::PaymentService#request_payment`
            (app/services/live/payment_service.rb). Messages exacts vérifiés
            le 2026-09-16.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingHeaderParams:
                  summary: En-tête d'authentification manquant
                  value: { message: 'Bad Request: Missing params' }
                notAllowedEnvironment:
                  summary: Ipay-Target-Environment non autorisé
                  value: { message: 'Bad Request: Not Allowed Environment' }
                notAllowedPaymentType:
                  summary: Ipay-Payment-Type non autorisé
                  value: { message: 'Bad Request: Not Allowed Payment Type' }
                missingAuthenticationKey:
                  summary: Authorization Bearer manquant ou mal formé
                  value: { message: 'Bad Request: Missing Authentication Key' }
                missingBodyParams:
                  summary: Champ requis manquant dans le corps (amount, country, currency, msisdn, transaction_id ou customer_name)
                  value: { message: 'Bad Request: Missing Body Params' }
                countryNotAllowed:
                  summary: country différent de NE ou BJ
                  value: { message: 'Bad Request: Country Not Allowed' }
                currencyNotAllowed:
                  summary: currency différente de XOF
                  value: { message: 'Bad Request: Currency Not Allowed' }
                amountNotValid:
                  summary: amount sous le minimum applicable (25 XOF en Live, 50 XOF en Sandbox)
                  value: { message: 'Bad Request: Amount Not Valid' }
                incorrectMsisdn:
                  summary: msisdn au format invalide pour la méthode
                  value: { message: 'Bad Request: Incorrect MSISDN' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
        '422':
          description: >-
            Référence externe (`transaction_id`) déjà utilisée pour ce compte :
            détection de doublon appliquée au niveau applicatif, non garantie
            sous appels concurrents — l'API n'expose aucune clé d'idempotence.
            Ce code couvre aussi un paiement invalide pour une autre raison de
            validation.
          x-verified: >-
            Note interne de vérification (non destinée à la documentation
            publique). Voir `Live::PaymentService#error_response`,
            app/services/live/payment_service.rb:994-1005.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                duplicateExternalReference:
                  summary: transaction_id déjà utilisé pour ce compte
                  value: { status: failed, message: External Reference Not Valid }
  /payments/{reference}:
    get:
      tags: [Payments]
      summary: Consulter une transaction
      description: Accepte la référence interne iPayMoney ou la référence externe du marchand.
      security:
        - bearerAuth: []
      parameters:
        - name: reference
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/ContentType'
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
      responses:
        '200':
          description: Transaction trouvée pour le compte authentifié
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: Transaction introuvable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
  /external_payments:
    post:
      tags: [Payment links]
      summary: Créer un lien de paiement hébergé
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TargetEnvironment'
        - name: Ipay-Payment-Type
          in: header
          required: true
          schema: { type: string, const: external_payment }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExternalPaymentRequest' }
            examples:
              lienCommande:
                summary: Lien de paiement d'une commande
                value:
                  title: 'Commande #1001'
                  description: 'Transaction de la commande #1001'
                  amount: 15000
                  on_success_redirection_url: https://votre-site.com/commandes/1001/succes
                  on_failed_redirection_url: https://votre-site.com/commandes/1001/echec
      responses:
        '200':
          description: Lien créé
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExternalPaymentCreateResponse' }
        '404':
          description: Compte introuvable ou inactif
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Paramètres invalides
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationError' }
  /external_payments/{reference}:
    get:
      tags: [Payment links]
      summary: Consulter un lien de paiement
      description: >-
        Cette route est actuellement accessible sans clé et peut retourner des
        données client. Utilisez une référence privée générée aléatoirement,
        jamais un identifiant de commande séquentiel.
      security: []
      parameters:
        - name: reference
          in: path
          required: true
          description: Référence privée à forte entropie, non publiée côté client.
          schema: { type: string, minLength: 32 }
      responses:
        '200':
          description: État du lien ou du paiement associé
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ExternalPaymentStatus'
                  - $ref: '#/components/schemas/ExternalPaymentWaiting'
                  - $ref: '#/components/schemas/ExternalPaymentExpired'
        '404':
          description: Lien introuvable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Clé secrète du compte Sandbox ou Live
  parameters:
    ContentType:
      name: Content-Type
      in: header
      required: true
      description: >-
        Exigé par l'authentification de l'API, y compris sur les requêtes GET
        qui n'ont pourtant pas de corps.
      schema: { type: string, const: application/json }
    TargetEnvironment:
      name: Ipay-Target-Environment
      in: header
      required: true
      schema:
        type: string
        enum: [sandbox, live]
    PaymentType:
      name: Ipay-Payment-Type
      in: header
      required: true
      description: >-
        Méthode de paiement. `POST /payments` ne prend en charge que
        `mobile` (mobile money). Les autres moyens de paiement (carte
        bancaire et autres canaux) ne s'utilisent pas ici : ils sont
        disponibles via les liens de paiement (`POST /external_payments`).
        Liste complète : [moyens de paiement](/guides/reversement#frais-par-moyen-de-paiement).
      x-verified: >-
        Note interne de vérification (non destinée à la documentation
        publique). Décision de périmètre : seule la valeur `mobile` est
        documentée pour `POST /payments` ; les autres valeurs traitées par
        `Live::PaymentService` restent acceptées par l'API mais relèvent du
        parcours « lien de paiement ». `external_payment` est réservé à
        `POST /external_payments` (paramètre dédié de cette opération).
      schema:
        type: string
        const: mobile
        example: mobile
  schemas:
    PaymentRequest:
      type: object
      description: >-
        Corps requis pour créer un paiement mobile money (`Ipay-Payment-Type:
        mobile`). Le montant minimum est de 25 XOF (50 XOF en Sandbox). En
        dessous, l'API répond 400 `Bad Request: Amount Not Valid`. Pour les
        autres moyens de paiement, utilisez les liens de paiement.
      x-verified: >-
        Note interne de vérification (non destinée à la documentation
        publique). Corps requis par `Live::PaymentService#request_payment`
        (app/services/live/payment_service.rb). Seuil `mobile` ≥ 25 vérifié le
        2026-09-16 (`verify_mobile_amount_value`). Les seuils des autres types
        ne sont plus documentés ici (périmètre réduit à `mobile`).
      required: [amount, country, currency, msisdn, transaction_id, customer_name]
      properties:
        amount:
          type: integer
          minimum: 25
          example: 1000
          description: >-
            Montant à débiter, en XOF (entier). Minimum : 25 XOF en Live,
            50 XOF en Sandbox.
        country: { type: string, enum: [BJ, NE] }
        currency: { type: string, const: XOF }
        msisdn: { type: string, example: '40410000000' }
        transaction_id: { type: string, description: Référence unique dans le compte }
        customer_name: { type: string }
    PaymentCreateResponse:
      type: object
      required: [status, reference, public_reference]
      properties:
        status: { type: string, example: succeeded }
        reference: { type: string }
        public_reference: { type: string }
        meta_data: { type: object, additionalProperties: true }
    PaymentStatus:
      type: object
      required: [public_reference, external_reference, reference, status, msisdn, amount]
      properties:
        public_reference: { type: string }
        external_reference: { type: string }
        reference: { type: string }
        status:
          type: string
          enum: [initiated, pending, succeeded, failed, refunded]
        msisdn: { type: string }
        amount: { type: integer }
        validated_at: { type: [string, 'null'], format: date-time }
    PaymentListItem:
      type: object
      properties:
        id: { type: integer }
        reference: { type: string }
        status: { type: string, example: Succeeded }
        amount: { type: integer }
        fees: { type: integer }
        payment_method:
          type: string
          example: Airtel Ne
          description: Moyen de paiement utilisé par le client — voir la liste des [moyens de paiement](/guides/reversement#frais-par-moyen-de-paiement).
        net_amount: { type: integer }
        platform: { type: string }
        device: { type: string }
        failure_reason: { type: [string, 'null'] }
        country: { type: string }
        currency: { type: string }
        external_reference: { type: string }
        msisdn: { type: string }
        customer_name: { type: string }
        created_at: { type: string, format: date-time }
        validated_at: { type: [string, 'null'], format: date-time }
    ExternalPaymentRequest:
      type: object
      required: [title]
      properties:
        title: { type: string }
        description: { type: string }
        amount: { type: [integer, 'null'], minimum: 50 }
        reference:
          type: string
          minLength: 32
          description: Omettre pour génération automatique, ou fournir une valeur aléatoire privée d'au moins 128 bits.
        shouldExpire:
          type: boolean
          const: true
          default: true
          description: La v1 actuelle crée toujours un lien expirant.
        on_success_redirection_url: { type: string, format: uri, pattern: '^https://' }
        on_failed_redirection_url: { type: string, format: uri, pattern: '^https://' }
    ExternalPaymentCreateResponse:
      type: object
      properties:
        title: { type: string }
        amount: { type: [integer, 'null'] }
        reference: { type: string }
        status: { type: string, example: initiated }
        should_expire: { type: boolean }
        page_url: { type: string, format: uri }
    ExternalPaymentStatus:
      type: object
      description: >-
        Forme renvoyée lorsqu'un paiement a été mené à son terme pour ce lien.
        `status` ne peut alors valoir que `succeeded` ou `failed` : sur cette
        opération, jamais `initiated`, `pending` ni `refunded`. Cas limite : un
        paiement remboursé après coup n'est plus rattaché au lien, et
        l'opération renvoie alors la forme d'un lien encore en attente
        (`status: initiated`) au lieu d'exposer le remboursement — ne pas
        utiliser cette opération comme indicateur fiable de remboursement.
      x-verified: >-
        Note interne de vérification (non destinée à la documentation
        publique). Vérifié le 2026-09-16 dans
        `Api::V1::ExternalPaymentsController#show`
        (app/controllers/api/v1/external_payments_controller.rb:41-59, 63-92) :
        ce schéma n'est rendu que pour
        `most_recent_successful || most_recent_failed`, deux scopes filtrés
        respectivement sur `payments.status = 2` et `payments.status = 3`
        (lignes 68-73), à comparer à l'énumération `Payment.status`
        (app/models/payment.rb:119 — initiated:0, pending:1, succeeded:2,
        failed:3, refunded:4). L'énumération existante était déjà exacte,
        confirmée plutôt que corrigée. Si un paiement `succeeded` est ensuite
        marqué `refunded` en base, il ne correspond plus à aucun des deux
        scopes SQL et cette route rend alors `ExternalPaymentWaiting`.
      properties:
        reference: { type: string }
        amount: { type: integer }
        country: { type: string }
        currency: { type: string }
        transaction_id: { type: string }
        msisdn: { type: string }
        customer_name: { type: string }
        status: { type: string, enum: [succeeded, failed] }
        created_at: { type: string, format: date-time }
    ExternalPaymentWaiting:
      type: object
      properties:
        message: { type: string }
        status: { type: string, const: initiated }
        reference: { type: string }
        page_url: { type: string, format: uri }
    ExternalPaymentExpired:
      type: object
      properties:
        message: { type: string }
        has_expire: { type: boolean, const: true }
        status: { type: string, const: cancelled }
        reference: { type: string }
    Error:
      type: object
      properties:
        message: { type: string }
        status:
          type: string
          description: >-
            Présent uniquement sur certains échecs applicatifs, ex. paiement
            dupliqué sur `POST /payments` où `status` vaut toujours `failed`.
            Absent des autres erreurs de ce contrat (ex. 404
            `Payment Not Found`, erreurs d'en-tête/authentification).
          x-verified: >-
            Note interne de vérification (non destinée à la documentation
            publique). Voir `Live::PaymentService#error_response`
            (app/services/live/payment_service.rb:994-1005).
          example: failed
    ValidationError:
      type: object
      properties:
        errors:
          type: array
          items: { type: string }
  responses:
    BadRequest:
      description: >-
        En-tête ou paramètre invalide, détecté avant toute logique métier.
      x-verified: >-
        Note interne de vérification (non destinée à la documentation
        publique). Détecté par `ApiAuthService#authenticate`
        (app/services/api_auth_service.rb:20-26) — messages exacts vérifiés
        le 2026-09-16.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missingHeaderParams:
              summary: En-tête d'authentification manquant
              value: { message: 'Bad Request: Missing params' }
            notAllowedEnvironment:
              summary: Ipay-Target-Environment non autorisé
              value: { message: 'Bad Request: Not Allowed Environment' }
            notAllowedPaymentType:
              summary: Ipay-Payment-Type non autorisé
              value: { message: 'Bad Request: Not Allowed Payment Type' }
            missingAuthenticationKey:
              summary: Authorization Bearer manquant ou mal formé
              value: { message: 'Bad Request: Missing Authentication Key' }
    Unauthorized:
      description: Clé invalide
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Compte ou environnement indisponible
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotAcceptable:
      description: Content-Type différent de application/json
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
