# LibrePay — spécification OpenAPI de l'API publique.
#
# CETTE SPEC DÉCRIT CE QUI EXISTE, PAS LA CIBLE.
#
# `docs/cadrage/06-api-publique.md` décrit l'API VISÉE. Ce fichier ne décrit que
# ce qui répond. Une spec qui décrirait la cible ferait écrire du code contre des
# routes inexistantes — c'est la façon la plus sûre de perdre la confiance d'un
# intégrateur. L'inverse coûte aussi cher : cet en-tête a affirmé pendant des
# semaines que payouts, lots, bénéficiaires et règlements n'existaient pas, alors
# qu'ils sont décrits quelques centaines de lignes plus bas.
#
# Ce qui est ici est appelable aujourd'hui, et vérifié par
# `qa/lot4_openapi_sdk.py` : chaque chemin décrit est éprouvé contre la sandbox
# réelle, et chaque code d'erreur documenté est provoqué.

openapi: 3.1.0

info:
  title: LibrePay — API publique
  version: '2026-08-25'
  description: |
    Encaissement mobile money en Côte d'Ivoire, par une intégration unique.

    **Conventions**

    - Montants : **entier en unité mineure**. Le franc CFA n'a pas de
      subdivision : `10000` vaut 10 000 F. Un montant en chaîne ou à décimales
      est refusé — deviner l'intention d'un montant est le début des ennuis.
    - Idempotence : `Idempotency-Key` est **obligatoire** sur tout POST qui
      déplace de l'argent. Rejouer la même clé avec le même corps rend la
      **réponse d'origine**, sans second mouvement.
    - Traçabilité : toute réponse porte `LibrePay-Request-Id`. C'est
      l'identifiant à citer au support, et celui qui apparaît dans le journal
      des requêtes de votre console.
    - Test et live : **la clé détermine l'environnement**. Aucun paramètre de
      mode, et aucune donnée ne traverse la frontière.

    **Le décaissement fait SORTIR de l'argent** et obéit donc à deux règles que
    l'encaissement n'a pas : il exige une clé de **portée `payout`**, distincte
    de celle qui encaisse, et il est soumis à des **plafonds** que le marchand
    règle dans sa console. Au-delà d'un plafond, l'appel n'est pas refusé — il
    rend `202` et attend une validation humaine.

    **Ce qui n'existe pas encore** : bénéficiaires en CRUD, règlements,
    pagination. Ne pas coder contre ces routes : elles ne répondent pas.

servers:
  - url: https://qkmvexkljdcfarulydao.supabase.co/functions/v1
    description: |
      Sandbox. **URL réelle d'aujourd'hui** — le domaine `api.valsoriapay.com`
      annoncé au cadrage n'est pas encore monté (il demande un proxy, cf. Q11).
      La documenter avant qu'elle existe ferait échouer chaque intégration.

security:
  - cleSecrete: []

tags:
  - name: Paiements
    description: Encaissements.
  - name: Décaissements
    description: |
      Envoi d'argent vers un bénéficiaire. Exige une clé de portée `payout`.
  - name: Checkout
    description: |
      Page de paiement hébergée : vous créez une session, vous envoyez son URL
      au payeur, nous nous chargeons du reste.
  - name: Lots de décaissement
    description: |
      Décaissement de masse. Un lot naît en brouillon, s'exécute par un appel
      distinct, et une ligne fautive n'annule pas les autres.
  - name: Webhooks
    description: |
      Les notifications que nous vous envoyons quand l'état d'une opération
      change. **C'est le seul moyen fiable d'être averti** : interroger l'API en
      boucle coûte cher aux deux parties et arrive toujours en retard.
  - name: Bénéficiaires
    description: |
      Le carnet des destinations vers lesquelles votre argent peut sortir.
      **En lecture seule par API** : une destination s'enregistre depuis votre
      console. Voir la description de la route pour la raison.
  - name: Règlements
    description: |
      Se faire verser ce qu'on vous doit, vers une destination de votre carnet.
      À ne pas confondre avec un décaissement : un règlement vous rend VOTRE
      argent depuis votre disponible, et il n'est pas facturé.

paths:
  /v1-payments:
    post:
      tags: [Paiements]
      operationId: creerPaiement
      summary: Créer un encaissement
      description: |
        Crée une intention d'encaissement. **La réponse ne dit pas que l'argent
        est arrivé** : le paiement naît `created`, passe `pending` pendant que le
        connecteur travaille, et n'est `succeeded` qu'une fois la preuve reçue de
        l'opérateur. Écoutez le webhook, ou interrogez `GET`.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `10000` = 10 000 F.
                  example: 10000
                currency:
                  type: string
                  pattern: '^[A-Za-z]{3}$'
                  default: XOF
                operator:
                  type: string
                  description: Opérateur visé, p. ex. `orange_ci`, `mtn_ci`.
                  example: orange_ci
                counterparty:
                  type: object
                  description: Le payeur.
                  properties:
                    msisdn:
                      type: string
                      example: '0779149021'
                merchant_reference:
                  type: string
                  description: |
                    Votre référence. **Unique par marchand** : une seconde
                    transaction portant la même valeur est refusée en 409. C'est
                    une protection contre le double encaissement, pas une gêne.
                description:
                  type: string
                metadata:
                  type: object
                  additionalProperties: true
                simulate:
                  type: string
                  enum: [succeed]
                  description: |
                    **Sandbox uniquement** : force l'issue sans passer par le
                    connecteur. Ignoré en live.
            examples:
              encaissement:
                value:
                  amount: 10000
                  currency: XOF
                  operator: orange_ci
                  counterparty: { msisdn: '0779149021' }
                  merchant_reference: CMD-2026-00412
      responses:
        '201':
          description: |
            Paiement créé.

            **Un rejeu d'idempotence rend AUSSI 201.** Même clé, même corps : la
            réponse d'origine est restituée telle quelle, statut compris, et
            aucun second mouvement n'a lieu. N'en déduisez donc pas qu'un
            paiement vient d'être créé — `201` signifie « voici le paiement qui
            correspond à cette clé », pas « un de plus ». Fiez-vous à l'`id`.
          headers:
            LibrePay-Request-Id: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Paiement' }
        '400':
          description: |
            `idempotency_key_required` — l'en-tête est obligatoire.
            `invalid_body` — corps JSON illisible.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: |
            `authentication_required` — aucune clé fournie.
            `invalid_api_key` — clé inconnue, révoquée ou malformée. Les trois
            cas rendent le **même** code : les distinguer aiderait à deviner des
            clés valides.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          description: |
            Deux causes, et le code les distingue.

            **`rate_limit_exceeded`** — vous appelez trop vite. Voir la réponse
            partagée plus bas : `Retry-After` dit quand revenir.

            **`too_many_attempts`** — trop de tentatives récentes pour LE MÊME
            PAYEUR. Ce n'est pas une limite sur vous, c'est une protection pour
            lui : une rafale de tentatives sur un même numéro est la signature
            du test de numéros volés. Un client qui se trompe de code recommence
            deux ou trois fois, pas dix. Le message est volontairement identique
            pour tout le monde — le préciser apprendrait à qui teste des numéros
            la cadence à tenir pour ne plus être vu.
          # LES EN-TÊTES SONT REPRIS ICI, et ce n'est pas une redondance : cette
          # route a une description propre (deux causes possibles), donc elle ne
          # peut plus pointer sur la réponse partagée. Les omettre aurait retiré
          # `Retry-After` de la seule route où l'on encaisse.
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`merchant_inactive` — le compte ne peut pas encaisser.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `idempotency_in_progress` — une requête identique est en cours.
            `duplicate_reference` — ce `merchant_reference` désigne déjà une
            autre transaction.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `payment_creation_failed` — une panne de notre côté, jamais une faute de votre
            requête. **Réessayez avec la MÊME clé d'idempotence** : c'est ce qui
            garantit qu'une opération ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, ou `idempotency_key_reused`
            (même clé, corps différent).

            **`amount_above_limit`** — le montant dépasse le plafond PAR
            OPÉRATION de votre compte. Ce seuil attrape la faute de frappe
            autant que la fraude : un montant très au-dessus de vos paiements
            habituels mérite d'être regardé avant d'être accepté. Si votre
            activité le justifie, écrivez-nous : il se relève.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Paiements]
      operationId: lirePaiement
      summary: Lire un paiement
      description: |
        L'identifiant se place en fin de chemin :
        `…/v1-payments/txn_XXXXXXXX`.

        Un paiement appartenant à un autre marchand rend **404**, jamais 403 :
        répondre 403 confirmerait son existence.
      parameters:
        - name: id
          in: query
          required: false
          schema: { type: string }
          description: Fourni en fin de CHEMIN, pas en paramètre. Voir la description.
      responses:
        '200':
          description: Le paiement.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Paiement' }
        '422':
          description: '`invalid_id` — l''identifiant ne ressemble pas à un `txn_…`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — inconnu, ou appartenant à un autre marchand.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payouts:
    post:
      tags: [Décaissements]
      operationId: creerDecaissement
      summary: Décaisser vers un bénéficiaire
      description: |
        **La seule route de cette API qui fait sortir de l'argent** vers une
        destination que vous choisissez. Elle obéit donc à deux règles propres.

        **1. La portée de la clé.** Une clé `collect` reçoit `403
        insufficient_scope`. Créez une clé dédiée au décaissement depuis votre
        console : la clé de votre site n'a alors aucun pouvoir d'envoi.

        **2. Les plafonds.** Par opération et par 24 heures glissantes, réglables
        dans votre console. Au-delà, l'appel rend **202** et non une erreur : la
        demande part en validation humaine, et rien ne bouge tant qu'un
        responsable n'a pas tranché depuis la console. Le plafond sur 24 h compte
        aussi les demandes en attente — sinon il suffirait d'en ouvrir cent.

        Les plafonds **bornent** ce qu'une clé compromise peut envoyer ; ils ne
        vérifient pas la destination. Gardez-les au plus près de votre usage réel.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `50000` = 50 000 F.
                currency: { type: string, default: XOF }
                beneficiary_id:
                  type: string
                  description: |
                    Un bénéficiaire de votre carnet. **Ou** `beneficiary` en
                    ligne — l'un des deux est requis.
                beneficiary:
                  type: object
                  description: |
                    Destination fournie en ligne. Elle est **enregistrée dans
                    votre carnet** : c'est ce qui vous permet de relire ensuite,
                    dans votre console, où votre argent est parti.
                  properties:
                    msisdn:
                      type: string
                      description: 10 chiffres, ou format international `+225…`.
                      example: '0700000021'
                    operator: { type: string, example: orange_ci }
                    name:
                      type: string
                      description: Le titulaire. Requis — c'est ce que vous relirez.
                      example: Koffi Y.
                reference:
                  type: string
                  description: Votre référence, reprise telle quelle.
                  example: SAL-08-2026-017
            examples:
              carnet:
                summary: Vers un bénéficiaire déjà enregistré
                value: { amount: 50000, beneficiary_id: '…', reference: SAL-08-2026-017 }
              enLigne:
                summary: Destination fournie à l'appel
                value:
                  amount: 50000
                  beneficiary: { msisdn: '0700000021', operator: orange_ci, name: Koffi Y. }
      responses:
        '201':
          description: |
            Décaissement enregistré. **Comme pour un encaissement, un rejeu
            d'idempotence rend AUSSI 201** avec la réponse d'origine : `201` veut
            dire « voici le décaissement de cette clé », pas « un de plus ».
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decaissement' }
        '202':
          description: |
            **Plafond dépassé : la demande attend une validation humaine.** Rien
            n'est parti. `reason` dit quel plafond (`plafond_operation` ou
            `plafond_24h`) et `limit` sa valeur. Ne traitez pas `202` comme un
            succès : suivez la demande par `GET /v1-payouts/por_…`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DemandeDecaissement' }
        '400':
          description: '`idempotency_key_required`, `invalid_body`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: |
            **`insufficient_scope`** — cette clé n'a pas la portée `payout`.
            C'est le cas d'une clé d'encaissement : elle ne peut pas décaisser,
            et c'est délibéré.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — bénéficiaire inconnu ou archivé.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `insufficient_funds` — votre solde de **transfert** ne couvre pas ce
            décaissement. Ce n'est pas votre solde de collecte : approvisionnez
            le transfert, ou balayez depuis la collecte.
            `idempotency_in_progress` — requête identique en cours.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `payout_failed` — une panne de notre côté, jamais une faute de votre
            requête. **Réessayez avec la MÊME clé d'idempotence** : c'est ce qui
            garantit qu'une opération ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, `invalid_beneficiary`,
            `idempotency_key_reused`.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Décaissements]
      operationId: lireDecaissement
      summary: Lire un décaissement ou une demande
      description: |
        L'identifiant se place en fin de chemin. Deux formes :
        `…/v1-payouts/txn_…` pour un décaissement, `…/v1-payouts/por_…` pour une
        demande en attente de validation.

        Une ressource appartenant à un autre marchand rend **404**, jamais 403.
      responses:
        '200':
          description: Le décaissement, ou la demande.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Decaissement'
                  - $ref: '#/components/schemas/DemandeDecaissement'
        '404':
          description: '`resource_not_found`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — ni `txn_…` ni `por_…`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches:
    post:
      tags: [Lots de décaissement]
      operationId: creerLotDecaissement
      summary: Créer un lot de décaissements
      description: |
        **Un lot n'est pas « n appels unitaires ».** Trois différences :

        - **le plafond porte sur le TOTAL** — sinon cent lignes sous le plafond
          unitaire passeraient pour dix fois le plafond journalier ;
        - **une ligne fautive n'annule pas les autres** à l'exécution ;
        - **l'idempotence porte sur le lot** — rejouer rend le même lot.

        **Le lot ne s'exécute PAS à sa création.** Il naît `brouillon` ;
        l'exécution est un appel distinct (`/execute`). Créer et payer d'un seul
        geste rendrait impossible de relire ce qu'on s'apprête à envoyer — or
        c'est exactement ce qu'on veut relire quand il y a cent lignes.

        **À la création, tout est validé ou rien n'est écrit.** Une ligne
        invalide refuse le lot entier, en donnant son **rang** : un lot
        partiellement retenu obligerait à comparer la source ligne à ligne,
        c'est-à-dire le travail qu'un lot évite.

        Au plus **500 lignes**. Au-delà, refus franc : un lot à moitié exécuté
        est le pire des états.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lines]
              properties:
                reference: { type: string, example: PAIE-2026-08 }
                currency: { type: string, default: XOF }
                lines:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [amount]
                    properties:
                      amount: { type: integer, minimum: 1, example: 30000 }
                      beneficiary_id: { type: string }
                      beneficiary:
                        type: object
                        properties:
                          msisdn: { type: string, example: '0700000021' }
                          operator: { type: string, example: orange_ci }
                          name: { type: string, example: Koffi Y. }
                      reference: { type: string }
      responses:
        '201':
          description: |
            Lot créé. `status` vaut `brouillon` — **rien n'est parti** — ou
            `en_attente_validation` si le total dépasse le plafond sur 24 h.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '400':
          description: '`idempotency_key_required`, `invalid_body`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`insufficient_scope` — clé sans la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: '`idempotency_in_progress`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_lines` — tableau absent ou vide.
            `too_many_lines` — plus de 500 lignes.
            `invalid_amount`, `invalid_beneficiary` — **le message porte le rang
            de la ligne fautive**.
            `idempotency_key_reused`.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

    get:
      tags: [Lots de décaissement]
      operationId: lireLotDecaissement
      summary: Lire un lot et ses lignes
      description: |
        `…/v1-payout-batches/pob_…`. Rend le lot **et le détail de ses lignes**,
        avec le statut et le motif d'échec de chacune.
      responses:
        '200':
          description: Le lot et ses lignes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '404':
          description: '`resource_not_found`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches/{id}/execute:
    post:
      tags: [Lots de décaissement]
      operationId: executerLotDecaissement
      summary: Exécuter un lot en brouillon
      description: |
        **C'est cet appel qui envoie l'argent.** La création n'envoie rien : un
        lot naît `brouillon` précisément pour qu'on puisse le relire avant.

        La description de la création citait cette route sans que la
        spécification la décrive — corrigé le 2026-09-07.

        **Une ligne fautive n'annule pas les autres.** Le lot revient avec le
        détail de chaque ligne et son motif d'échec ; on reprend les rangs en
        échec, pas le lot entier.

        Exige la portée `payout`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: pob_7cP2rQ1s }
      responses:
        '200':
          description: Le lot exécuté, avec le détail de ses lignes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '404':
          description: '`resource_not_found` — lot inconnu, ou d''un autre mode.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `batch_pending_approval` — le lot dépasse un plafond et attend une
            validation dans la console ; ou `batch_not_executable` — un lot déjà
            exécuté, annulé ou terminé ne se rejoue pas.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-payout-batches/{id}/cancel:
    post:
      tags: [Lots de décaissement]
      operationId: annulerLotDecaissement
      summary: Annuler un lot avant exécution
      description: |
        N'annule que ce qui **n'a rien envoyé** : un lot `brouillon` ou en
        attente de validation. Un lot en cours a des lignes parties, et
        « annuler » laisserait croire qu'elles sont rappelées — elles ne le sont
        pas.

        Exige la portée `payout`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: pob_7cP2rQ1s }
      responses:
        '200':
          description: Le lot, désormais `annule`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LotDecaissement' }
        '409':
          description: |
            `batch_not_cancellable` — lot inconnu, ou déjà exécuté. Les lignes
            parties ne se rappellent pas.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: '`invalid_id` — identifiant `pob_…` attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-checkout:
    post:
      tags: [Checkout]
      operationId: creerSessionCheckout
      summary: Créer une session de paiement hébergée
      description: |
        **Quand la préférer à `/v1-payments`.** Vous n'avez alors ni numéro à
        collecter, ni écran à construire, ni opérateur à choisir : vous envoyez
        une URL. La page est tenue à jour par nos soins, y compris quand un
        opérateur change son parcours.

        La session naît `open` et **expire**. `expires_at` le dit ; passé ce
        délai, le lien affiche « demande expirée » plutôt qu'un formulaire mort.

        Exige la portée `collect` — c'est un encaissement.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: Unité mineure, entier strict. `10000` = 10 000 F.
                currency: { type: string, default: XOF }
                description:
                  type: string
                  description: |
                    Ce que le PAYEUR lira sur la page. Mettez-y votre référence
                    de commande : c'est ce qui lui permet de reconnaître ce
                    qu'il paie.
                  example: Commande #4821
                return_url:
                  type: string
                  format: uri
                  description: |
                    Où renvoyer le payeur après un paiement réussi. **HTTPS
                    obligatoire** — un retour en clair exposerait la référence
                    de commande dans l'URL.
      responses:
        '201':
          description: |
            Session créée. **`url` est ce que vous transmettez au payeur** — ne
            reconstruisez pas ce lien vous-même, le jeton n'est pas devinable et
            c'est ce qui protège la session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionCheckout' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `collect`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, ou `invalid_return_url`
            (l'URL de retour n'est pas en HTTPS).

            Sur un lien à montant libre, le payeur peut aussi recevoir
            `amount_too_small` ou `amount_too_large` — les bornes du lien sont
            rendues avec la session, affichez-les avant la saisie plutôt que de
            laisser découvrir un plancher après coup.

            Au moment où le payeur valide : `invalid_msisdn` (numéro ivoirien à
            dix chiffres attendu) et `invalid_operator` (opérateur hors de ceux
            que la session propose).
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            La session ne peut plus recevoir de paiement :
            `session_expired` (le délai est passé), `session_closed` (elle est
            déjà payée ou fermée), `lien_indisponible` (le lien réutilisable a
            été fermé ou a expiré).

            Ces trois-là ne se réessaient pas : créez une nouvelle session.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `checkout_creation_failed`, `payment_failed` ou `bornes_illisibles` —
            une panne de notre côté, jamais une faute de votre requête. Réessayez
            avec la MÊME clé d'idempotence : c'est ce qui garantit qu'un
            encaissement ne partira pas deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-beneficiaries:
    get:
      tags: [Bénéficiaires]
      operationId: listerBeneficiaires
      summary: Lire votre carnet de destinations
      description: |
        **À quoi ça sert.** `/v1-settlements` exige `beneficiary` :
        l'identifiant d'une destination déjà enregistrée. Cette route vous le
        donne, pour que vous n'ayez pas à le relever à la main dans la console.

        Sans paramètre, rend vos destinations. Avec un UUID **en fin de
        chemin** (`…/v1-beneficiaries/<uuid>`), rend celle-là seule — une
        destination d'un autre marchand rend **404**, jamais 403.

        `?active=true` ne rend que les destinations actives. Par défaut on rend
        **tout, archivées comprises** : une destination archivée explique un
        règlement passé, et la masquer ferait chercher un fantôme.

        ### Ce qu'on ne rend jamais

        Ni l'IBAN complet, ni le numéro complet — seulement `last4`. Une réponse
        d'API se retrouve dans vos journaux, dans un cache de proxy, dans une
        capture d'écran de support. Quatre chiffres suffisent à reconnaître une
        destination qu'on a soi-même enregistrée.

        Exige la portée `payout` : le carnet dit vers où l'argent peut sortir.
      parameters:
        - name: active
          in: query
          required: false
          schema: { type: string, enum: ['true', 'false'] }
          description: Ne rendre que les destinations actives, ou que les archivées.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: |
            Votre carnet. `has_more` vous évite de compter : une page pleine ne
            signifie pas que vous avez tout vu.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Beneficiaire' }
                  total_count: { type: integer, example: 7 }
                  limit: { type: integer, example: 50 }
                  offset: { type: integer, example: 0 }
                  has_more: { type: boolean, example: false }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: '`resource_not_found` — cette destination n''existe pas, ou n''est pas la vôtre.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '405':
          description: |
            `method_not_allowed` — seuls `GET` et `POST` sont acceptés. Une
            destination s'archive depuis la console.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
    post:
      tags: [Bénéficiaires]
      operationId: creerBeneficiaire
      summary: Enregistrer une destination dans votre carnet
      description: |
        **À quoi ça sert.** Constituer votre carnet sans passer par la console,
        et surtout sans dépenser d'argent pour le faire.

        ### Pourquoi cette route existe depuis le 2026-09-04

        Elle n'existait pas, au motif qu'une clé compromise aurait pu s'ajouter
        une destination puis s'y faire régler — alors qu'il fallait, disait-on,
        compromettre aussi une session de console.

        **Ce second obstacle n'existait pas.** `POST /v1-payouts` accepte une
        destination *en ligne* et l'enregistre au carnet, sous la même portée
        `payout`. Une clé compromise pouvait donc déjà s'en fabriquer une ; il
        lui en coûtait un décaissement. Refuser cette route n'empêchait rien —
        elle obligeait seulement l'intégrateur honnête à faire sortir de l'argent
        pour enregistrer un numéro.

        ### Idempotente sans clé d'idempotence

        Le carnet est unique par `(marchand, mode, numéro)` parmi les
        destinations actives. Réenvoyer la même destination **ne crée pas de
        doublon** : vous recevez `200` avec la ligne existante, contre `201`
        à la création. Aucun en-tête `Idempotency-Key` n'est requis.

        **Elle ne crée que des destinations de DÉCAISSEMENT.** La destination de
        vos règlements — celle vers laquelle nous vous versons ce que nous vous
        devons — ne se crée pas par API : elle se désigne dans votre console,
        par le propriétaire du compte, avec un code de confirmation. C'est ce qui
        empêche une clé volée de rediriger vos règlements.

        Exige la portée `payout`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [msisdn, operator, name]
              properties:
                msisdn:
                  type: string
                  description: |
                    Dix chiffres (`0700000021`) ou format international
                    (`+2250700000021`). Normalisé avant enregistrement : les
                    trois écritures d'un même numéro donnent une seule ligne.
                  example: '0700000021'
                operator:
                  type: string
                  description: Le réseau de la destination.
                  example: orange_ci
                name:
                  type: string
                  minLength: 2
                  description: |
                    Ce que vous relirez dans votre console. C'est votre libellé,
                    pas une donnée vérifiée auprès de l'opérateur.
                  example: Awa Koné
            examples:
              directe:
                summary: Champs à plat
                value: { msisdn: '0700000021', operator: orange_ci, name: Awa Koné }
              enveloppee:
                summary: Sous « beneficiary », comme dans un décaissement
                value:
                  beneficiary: { msisdn: '0700000021', operator: orange_ci, name: Awa Koné }
      responses:
        '201':
          description: La destination a été créée.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Beneficiaire' }
        '200':
          description: |
            Cette destination était **déjà** à votre carnet ; elle vous est
            rendue telle quelle. Rien n'a été créé.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Beneficiaire' }
        '400':
          description: '`invalid_request` — corps JSON attendu.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_beneficiary` — numéro illisible, `operator` absent, ou
            `name` de moins de deux caractères.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-statements:
    get:
      tags: [Relevés]
      operationId: lireReleve
      summary: Le relevé de compte sur une période
      description: |
        **Ce qui répond à « d'où vient mon solde ? ».** Ouverture, mouvements
        dans l'ordre avec un solde qui court, clôture — la forme d'un relevé
        bancaire, faite pour être versée dans une comptabilité.

        **L'invariant qui rend ce document utilisable** :
        `opening_balance + total_in − total_out = closing_balance`, et un relevé
        arrêté aujourd'hui clôture **exactement** sur votre solde disponible. Un
        relevé qui ne reproduit pas ce solde serait pire qu'aucun relevé.

        **Les quatre bornes portent sur la PÉRIODE, jamais sur la page.** Elles
        ne bougent pas quand vous paginez — n'additionnez pas `data` pour les
        recalculer, vous obtiendriez un total faux dès la deuxième page.

        **Ce n'est pas « le relevé d'un règlement ».** Un règlement est un
        retrait d'un montant choisi sur votre disponible, pas une clôture de
        période ; il figure ici comme une sortie, à sa date.

        Exige la portée `collect` — cette route LIT, elle ne déplace rien.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date, example: '2026-09-01' }
          description: Premier jour inclus, `AAAA-MM-JJ`.
        - name: to
          in: query
          required: true
          schema: { type: string, format: date, example: '2026-09-30' }
          description: |
            Dernier jour **inclus**. La journée entière compte : un relevé
            arrêté au 30 contient ce qui s'est passé le 30.
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: |
            Le relevé. Une période **sans mouvement** rend les bornes à zéro et
            `data` vide — pas une erreur : « rien n'a bougé » est une réponse.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Releve' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `collect`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_period` — `from` ou `to` absent, mal formé, ou début
            postérieur à la fin. **Un intervalle inversé est refusé**, jamais
            rendu vide : un relevé vide se lit « ce compte n'a pas bougé ».
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '500':
          description: '`statement_failed` — panne de notre côté.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

  /v1-settlements:
    post:
      tags: [Règlements]
      operationId: demanderReglement
      summary: Demander à être réglé
      description: |
        **Ce n'est pas un décaissement.** Un décaissement (`/v1-payouts`) paie un
        TIERS depuis votre solde de transfert, que vous avez approvisionné, et
        il est facturé. Un règlement vous rend à VOUS ce que nous vous devons,
        depuis votre disponible, et il n'est pas facturé — on ne fait pas payer
        quelqu'un pour être remboursé.

        **Elle rend `202`, jamais `201`.** Une demande est ACCEPTÉE, pas
        exécutée : elle attend une validation de nos équipes. Un intégrateur qui
        traiterait un `2xx` comme « l'argent est parti » se tromperait ; le code
        HTTP le dit autant que le champ `status`.

        **Exige la portée `payout`.** Cette route fait sortir de l'argent : une
        clé de site marchand compromise ne doit pas pouvoir vider le compte.

        ### La destination ne se choisit pas par API *(depuis le 2026-09-05)*

        `beneficiary` doit être **la destination de règlement désignée** par le
        propriétaire du compte, depuis la console, dans l'écran « Règlements ».
        Il n'y en a qu'une à la fois.

        Ce n'est pas une formalité : `POST /v1-payouts` enregistre une
        destination à la volée, et `POST /v1-beneficiaries` en crée une
        explicitement — toutes deux sous la portée `payout`. Sans cette règle,
        une clé compromise n'aurait eu qu'à s'ajouter sa propre destination puis
        à appeler cette route : **la portée `payout` aurait servi à contourner
        la portée `payout`**. Un décaissement paie un tiers ; décider où le
        marchand se fait payer n'appartient pas à une clé.

        Une destination du carnet de décaissement est donc refusée ici
        (`destination_non_designee`), et une destination fraîchement désignée
        attend un délai avant de servir (`destination_trop_recente`) — le
        marchand et son équipe en sont avertis par courriel, pour qu'un
        changement non voulu laisse le temps de réagir.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, beneficiary]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: |
                    Unité mineure, **entier JSON strict**. `"50000"` est refusé :
                    sur un montant qui sort, la conversion implicite d'une
                    chaîne est une faute, pas une commodité.
                  example: 50000
                beneficiary:
                  type: string
                  format: uuid
                  description: Identifiant d'une destination de votre carnet.
      responses:
        '202':
          description: |
            Demande **acceptée**, en attente de validation. Rien n'est encore
            versé. Suivez `status` — ou l'événement `settlement.paid`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reglement' }
        '400':
          description: '`invalid_body` — le corps n''est pas du JSON valide.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '403':
          description: '`insufficient_scope` — cette clé n''a pas la portée `payout`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '404':
          description: |
            `destination_introuvable` — cette destination n'est pas dans votre
            carnet. Enregistrez-la d'abord depuis votre console.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '409':
          description: |
            `idempotency_key_reused` (même clé, corps différent) ou
            `idempotency_in_progress` (une requête portant cette clé est en cours).
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `idempotency_key_required`, `invalid_amount`, `invalid_beneficiary`,
            `sous_le_plancher` (montant sous le minimum de versement) ou
            `montant_non_eligible` (vous ne disposez pas de cette somme).
            `destination_non_designee` — cette destination sert aux
            décaissements ; celle des règlements se désigne en console.
            `destination_trop_recente` — désignée il y a moins de N heures ; le
            message dit à partir de quand elle servira.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '500':
          description: |
            `settlement_failed` — une panne de notre côté, jamais une faute de
            votre requête. **Réessayez avec la MÊME clé d'idempotence** : c'est
            ce qui garantit qu'une demande ne sera pas déposée deux fois.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
    get:
      tags: [Règlements]
      operationId: listerReglements
      summary: Lister vos demandes, ou en lire une
      description: |
        Sans rien, rend vos demandes les plus récentes.

        Pour en lire une seule, l'identifiant se place **en fin de chemin** :
        `…/v1-settlements/stl_XXXXXXXX` — comme pour les paiements. Un
        règlement appartenant à un autre marchand rend **404**, jamais 403 :
        répondre 403 confirmerait son existence.
      parameters:
        - name: id
          in: query
          required: false
          schema: { type: string }
          description: Fourni en fin de CHEMIN, pas en paramètre. Voir la description.
      responses:
        '200':
          description: La liste, ou la demande demandée.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reglement' }
        '401':
          description: '`authentication_required`, `invalid_api_key`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '429':
          $ref: '#/components/responses/TropDAppels'
        '404':
          description: '`resource_not_found` — cette demande n''existe pas, ou n''est pas la vôtre.'
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

# ═══ WEBHOOKS ═══
#
# Documentés ici parce qu'ils ne l'étaient nulle part : la spec ne contenait
# qu'une seule occurrence du mot « webhook » et aucune du mot « signature ».
# Un intégrateur pouvait donc configurer un endpoint depuis sa console et
# n'avoir aucun moyen de vérifier ce qu'il recevait. Relevé le 2026-09-02.
#
# Les valeurs ci-dessous sont celles du code déployé (`_shared/webhook.ts`,
# `worker-webhooks`) — pas une intention.
webhooks:
  evenement:
    post:
      tags: [Webhooks]
      operationId: recevoirEvenement
      summary: Ce que nous envoyons à votre endpoint
      description: |
        Nous appelons l'URL que vous avez enregistrée en `POST`, avec le corps
        JSON ci-dessous et trois en-têtes :

        | En-tête | Contenu |
        |---|---|
        | `LibrePay-Signature` | `t=<horodatage unix>,v1=<HMAC-SHA256 hexadécimal>` |
        | `LibrePay-Event-Id` | `evt_…` — l'identifiant de l'événement |
        | `User-Agent` | `ValsoriaPay-Webhooks/1.0` |

        ### Vérifier la signature

        Le HMAC-SHA256 porte sur **`t + "." + corps brut`**, avec votre secret
        d'endpoint comme clé — **pas sur le corps seul**. Sans l'horodatage dans
        le message signé, on pourrait rejouer un ancien appel authentique en
        changeant simplement le `t` affiché.

        ```
        message = "1755770000." + corps_brut_exact
        attendu = hex(hmac_sha256(secret_endpoint, message))
        valide  = attendu == v1  et  |maintenant - t| <= 300 secondes
        ```

        Signez sur le corps **tel qu'il est arrivé**, avant tout parsing : le
        moindre reformatage JSON change l'empreinte.

        La tolérance est de **300 secondes**. Une signature authentique mais
        plus ancienne est refusée — c'est ce qui borne le rejeu.

        ### Répondre

        Répondez **2xx** dès que vous avez enregistré l'événement, sans attendre
        votre propre traitement. Toute autre réponse, ou un dépassement de
        délai, déclenche une nouvelle tentative avec un intervalle croissant.
        Les livraisons échouées sont visibles dans votre console, et rejouables.

        ### Traitez-les comme idempotents

        Un même `LibrePay-Event-Id` peut vous parvenir plusieurs fois — un rejeu,
        une réponse perdue en chemin. Déduisez-en votre état à partir de
        `data.status`, et ignorez un identifiant déjà vu.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Evenement' }
      responses:
        '200':
          description: |
            Reçu. **Toute réponse hors 2xx entraîne une nouvelle tentative** —
            y compris un 3xx : une redirection n'est pas un accusé de réception.

components:
  securitySchemes:
    cleSecrete:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer vp_sk_test_…` en sandbox,
        `vp_sk_live_…` en production. **Serveur à serveur uniquement** : une clé
        secrète dans une application mobile ou une page web est une clé publiée.

        ### Restreignez votre clé à vos adresses

        Depuis votre console, onglet « Clés et intégration », vous pouvez
        n'autoriser une clé que depuis certaines adresses — une adresse
        (`41.202.0.7`) ou un bloc (`41.202.0.0/24`). Une clé qui fuite devient
        alors inutilisable ailleurs. C'est la protection la plus efficace du
        lot, et elle ne coûte rien à poser.

        **Un appel refusé par la restriction rend le même `401 invalid_api_key`
        qu'une clé inconnue** — nous ne dirons jamais à un appelant qu'il tient
        une clé valable mais appelle du mauvais endroit. Vous, en revanche, le
        voyez : votre console affiche la dernière adresse refusée et l'instant.
        C'est là qu'il faut regarder si une intégration cesse soudain de passer.

        Vider la liste retire la restriction. Se tromper d'adresse ne vous
        enferme donc jamais dehors : la console s'authentifie par session, pas
        par clé.

  parameters:
    CleIdempotence:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 8 }
      description: |
        Obligatoire. Un identifiant que VOUS choisissez et conservez : c'est lui
        qui garantit qu'un appel rejoué — après un délai réseau, par exemple — ne
        déplace pas l'argent deux fois.

  headers:
    RequestId:
      description: |
        Identifiant de la requête, à citer au support.

        **Transition de nom, depuis le 2026-09-07.** La plateforme s'appelle
        LibrePay ; les en-têtes portaient encore l'ancienne marque. Nous
        envoyons donc **les deux** — `LibrePay-Request-Id` et
        `Valsoria-Request-Id` — avec la même valeur. Il en va de même pour
        `LibrePay-Signature` / `Valsoria-Signature` et `LibrePay-Event-Id` /
        `Valsoria-Event-Id` sur les webhooks.

        **Lisez le nouveau nom, avec l'ancien en repli.** Les anciens ne
        disparaîtront pas sans une annonce préalable, et jamais en silence.
      schema: { type: string, format: uuid }
    RetryAfter:
      description: |
        Secondes à attendre avant de réessayer. **Respectez-le** : réessayer
        avant produit exactement le martèlement que la limite refuse.
      schema: { type: integer, example: 42 }
    RateLimitLimit:
      description: Le plafond en vigueur, en appels par minute.
      schema: { type: integer, example: 300 }
    RateLimitRemaining:
      description: Ce qu'il reste dans la fenêtre courante.
      schema: { type: integer, example: 0 }
    RateLimitReset:
      description: Secondes avant que la fenêtre reparte à zéro.
      schema: { type: integer, example: 42 }

  responses:
    TropDAppels:
      description: |
        **`rate_limit_exceeded`** — vous avez dépassé le nombre d'appels permis
        dans la minute.

        ### Deux limites, et elles ne comptent pas la même chose

        - **par clé API**, 300 appels/minute par défaut. C'est la limite qui vous
          concerne. Elle porte sur l'identifiant de votre clé : personne d'autre
          ne peut consommer votre quota, même en connaissant son préfixe.
        - **par adresse IP**, 600 appels/minute par défaut. Elle s'applique avant
          l'authentification et vise un martèlement sans clé valable. Vous ne la
          rencontrerez que si plusieurs de vos serveurs sortent par la même
          adresse en appelant très fort.

        Les deux plafonds se règlent de notre côté sans mise en ligne : si votre
        volume légitime les dépasse, **écrivez-nous** plutôt que de contourner.

        ### Ce que la réponse vous donne pour réagir

        `Retry-After` dit en combien de secondes réessayer, et `RateLimit-Limit`
        rappelle le plafond appliqué. Un client sérieux les lit et ralentit ; un
        client qui reboucle aggrave son cas.

        ### La fenêtre est FIXE, et on le dit

        Le compteur repart à zéro à chaque minute pleine, il ne glisse pas. À
        cheval sur deux fenêtres, vous pouvez donc envoyer jusqu'à deux fois le
        plafond en un instant sans être refusé. Ne construisez pas votre cadence
        là-dessus : c'est une propriété de l'implémentation, pas une promesse.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Erreur' }

  schemas:
    Paiement:
      type: object
      properties:
        id: { type: string, example: txn_6INMt2lIL1Pv_y6RgqZvag }
        object: { type: string, enum: [payment] }
        amount:
          type: integer
          example: 10000
          description: Ce que le marchand a demandé, en unité mineure.
        amount_collected:
          type: integer
          example: 10000
          description: |
            Ce que le PAYEUR débourse. Égal à `amount` quand le marchand
            supporte les frais ; `amount + fee_amount` quand c'est le payeur —
            le barème le décide, par marchand et par service.
        fee_amount:
          type: integer
          example: 150
          description: |
            Commission LibrePay, **figée à la création** : un changement de
            barème ne modifie jamais une transaction passée. Rendue dès la
            réponse de création, avant que l'argent ne bouge.
        net_amount:
          type: integer
          example: 9850
          description: |
            Ce que le marchand touche réellement, crédité à la confirmation.
            Un remboursement contre-passe la commission au prorata : rembourser
            intégralement ne coûte donc aucune commission au marchand.
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [created, pending, succeeded, failed, expired, refunded, partially_refunded]
          description: |
            `created` : enregistré. `pending` : le connecteur travaille.
            `succeeded` : la preuve est arrivée. `failed` : refus établi.

            **Il n'existe pas d'état « probablement réussi ».** Tant qu'une issue
            est incertaine, le paiement reste `pending` — jamais `failed`, ce qui
            vous ferait conclure à tort qu'aucun argent n'est parti.
        operator: { type: [string, 'null'] }
        merchant_reference: { type: [string, 'null'] }
        failure_code: { type: [string, 'null'] }
        failure_message: { type: [string, 'null'] }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        succeeded_at: { type: [string, 'null'], format: date-time }
        failed_at: { type: [string, 'null'], format: date-time }

    Decaissement:
      type: object
      properties:
        id: { type: string, example: txn_6INMt2lIL1Pv_y6RgqZvag }
        object: { type: string, enum: [payout] }
        amount:
          type: integer
          description: Ce que reçoit le bénéficiaire.
          example: 50000
        fee_amount:
          type: integer
          description: Commission, **figée à la création**.
          example: 250
        total_debited:
          type: integer
          description: Ce qui sort de votre solde de transfert — `amount + fee_amount`.
          example: 50250
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [created, processing, succeeded, failed, indeterminate]
          description: |
            `indeterminate` : l'opérateur n'a pas confirmé. **Nous ne rejouons
            jamais un envoi incertain** — ne le rejouez pas non plus.
        beneficiary:
          type: object
          properties:
            name: { type: [string, 'null'] }
            label: { type: [string, 'null'] }
            msisdn: { type: [string, 'null'] }
        reference: { type: [string, 'null'] }
        failure_code: { type: [string, 'null'] }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }

    DemandeDecaissement:
      type: object
      description: |
        Un décaissement **retenu** par un plafond. Rien n'est parti ; un humain
        doit trancher depuis la console.
      properties:
        id: { type: string, example: por_3f2c1a90-1d4e-4e2b-9c11-0a7d5e2b4f88 }
        object: { type: string, enum: [payout_request] }
        status:
          type: string
          enum: [pending_approval, approuve, refuse, expire]
        amount: { type: integer, example: 150000 }
        currency: { type: string, example: XOF }
        reason:
          type: string
          enum: [plafond_operation, plafond_24h]
          description: Quel plafond est en cause — pour savoir lequel relever.
        limit:
          type: integer
          description: La valeur du plafond atteint.
          example: 100000
        reference: { type: [string, 'null'] }
        expires_at:
          type: string
          format: date-time
          description: Passé ce délai, la demande n'est plus validable — refaites l'appel.
        created_at: { type: string, format: date-time }
        message: { type: string }

    LotDecaissement:
      type: object
      properties:
        id: { type: string, example: pob_3f2c1a90-1d4e-4e2b-9c11-0a7d5e2b4f88 }
        object: { type: string, enum: [payout_batch] }
        status:
          type: string
          enum: [brouillon, en_attente_validation, en_cours, termine,
                 termine_avec_erreurs, annule, refuse]
          description: |
            `brouillon` : créé, **rien n'est parti**. `en_attente_validation` :
            le total dépasse le plafond, une validation humaine est requise en
            console. `termine_avec_erreurs` : certaines lignes ne sont pas
            parties — **ce n'est pas `termine`**, et le confondre ferait passer
            des lignes impayées pour une paie faite.
        reference: { type: [string, 'null'] }
        total: { type: integer, example: 120000 }
        line_count: { type: integer, example: 3 }
        currency: { type: string, example: XOF }
        reason:
          type: string
          enum: [plafond_operation, plafond_24h]
          description: Présent seulement si le lot attend une validation.
        limit: { type: integer }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        lines:
          type: array
          description: Présent sur `GET` et après exécution.
          items:
            type: object
            properties:
              rank:
                type: integer
                description: L'ordre du fichier fourni — c'est par lui qu'on retrouve une ligne fautive.
              amount: { type: integer }
              reference: { type: [string, 'null'] }
              status: { type: string, enum: [a_envoyer, envoye, echoue, ignore] }
              payout_id: { type: [string, 'null'], example: txn_… }
              failure_code: { type: [string, 'null'] }
              failure_message: { type: [string, 'null'] }

    SessionCheckout:
      type: object
      properties:
        id: { type: string, example: cs_JBpm7AnJZvBBRHcloKKIfg }
        object: { type: string, enum: [checkout_session] }
        amount: { type: integer, example: 10000 }
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [open, processing, succeeded, failed, expired, cancelled]
        expires_at: { type: string, format: date-time }
        url:
          type: string
          format: uri
          description: |
            Le lien à transmettre au payeur. Le jeton qu'il porte n'est pas
            devinable — c'est ce qui protège la session.
          example: https://pay.valsoriapay.avasoftware.net/c/tok_…

    Beneficiaire:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [beneficiary] }
        type: { type: string, enum: [mobile_money, bancaire] }
        label: { type: string, example: Mon compte Orange }
        holder: { type: string, example: Awa Koné }
        operator:
          type: string
          nullable: true
          description: |
            Pour une destination mobile money. Le préfixe du numéro doit
            correspondre au réseau : **07** Orange, **05** MTN, **01** Moov.
            Wave accepte les trois — un compte Wave est adossé à l'une de ces
            lignes.
          example: orange_ci
        bank: { type: string, nullable: true }
        last4:
          type: string
          nullable: true
          description: |
            Les quatre derniers chiffres du numéro ou de l'IBAN. **Jamais la
            valeur complète** : une réponse d'API vit plus longtemps qu'on ne le
            croit.
          example: '7777'
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    Releve:
      type: object
      properties:
        object: { type: string, enum: [statement] }
        from: { type: string, format: date, example: '2026-09-01' }
        to: { type: string, format: date, example: '2026-09-30' }
        currency: { type: string, example: XOF }
        opening_balance:
          type: integer
          description: Votre solde disponible la veille du premier jour, en unité mineure.
          example: 100000
        closing_balance:
          type: integer
          description: |
            Le solde au soir du dernier jour. Sur un relevé arrêté aujourd'hui,
            **il vaut votre disponible** — c'est ce qui permet de rapprocher.
          example: 138000
        total_in: { type: integer, example: 50000 }
        total_out: { type: integer, example: 12000 }
        data:
          type: array
          items:
            type: object
            properties:
              object: { type: string, enum: [statement_line] }
              date: { type: string, format: date-time }
              description:
                type: string
                description: Ce qui s'est passé, en clair — « Encaissement confirmé », « Règlement ».
                example: Encaissement confirmé
              reference:
                type: [string, 'null']
                description: L'identifiant de la transaction, quand la ligne en a une.
                example: txn_6INMt2lIL1Pv_y6RgqZvag
              in:
                type: integer
                description: |
                  Ce qui est entré. **Deux colonnes plutôt qu'un montant signé** :
                  un signe se perd à la relecture, deux colonnes ne se confondent pas.
              out: { type: integer }
              balance:
                type: integer
                description: Le solde APRÈS cette ligne.
        total_count:
          type: integer
          description: Le nombre de mouvements de la PÉRIODE, pas de la page.
        limit: { type: integer }
        offset: { type: integer }
        has_more: { type: boolean }

    Reglement:
      type: object
      properties:
        id: { type: string, example: stl_7bQ2mVx4KpLdR9nZ }
        object: { type: string, enum: [settlement] }
        amount: { type: integer, example: 50000 }
        currency: { type: string, example: XOF }
        status:
          type: string
          enum: [demande, validee, payee, refusee, annulee, echouee]
          description: |
            `demande` à l'acceptation. Elle passe `validee` quand nos équipes
            l'approuvent, puis `payee` quand le versement est exécuté. **Seul
            `payee` signifie que l'argent est parti.**
        destination:
          type: object
          description: |
            La destination, jamais en entier : ni IBAN complet, ni numéro
            complet. Une réponse d'API finit dans les journaux de l'intégrateur.
          properties:
            label: { type: string, nullable: true, example: Mon compte Ecobank }
            type: { type: string, nullable: true, enum: [mobile_money, bancaire] }
            holder: { type: string, nullable: true }
        decline_reason:
          type: string
          nullable: true
          description: Renseigné lorsque `status` vaut `refusee`.
        decided_at: { type: string, format: date-time, nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }

    Evenement:
      type: object
      properties:
        id: { type: string, example: evt_WcrqCQJZDdp2lZ9bkns7kg }
        object: { type: string, enum: [event] }
        type:
          type: string
          description: |
            Les sept types émis aujourd'hui. Cette liste est celle du code, pas
            une intention — elle s'allongera, alors **ignorez poliment un type
            que vous ne connaissez pas** plutôt que d'échouer dessus.
          enum:
            - payment.succeeded
            - payment.failed
            - payment.refunded
            - payment.partially_refunded
            - payout.pending
            - payout.succeeded
            - payout.failed
        api_version:
          type: string
          example: '2026-08-21'
          description: La version de format de `data`. Elle ne change jamais rétroactivement.
        created_at: { type: string, format: date-time }
        data:
          type: object
          description: |
            L'objet concerné, dans la même forme que celle rendue par l'API —
            un `Paiement` pour un `payment.*`, un `Decaissement` pour un
            `payout.*`.
          example:
            id: txn_-2SyMN03-cG3Ge8T9zDHVw
            object: payment
            status: succeeded
            amount: 10000
            amount_collected: 10000
            fee_amount: 0
            net_amount: 10000
            currency: XOF
            operator: orange_ci
            merchant_reference: null
            created_at: '2026-09-02T17:41:57Z'

    Erreur:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Code stable, à traiter en machine.
              example: invalid_amount
            message:
              type: string
              description: Phrase destinée à un humain. **Ne pas la comparer** — elle peut changer.
