# Valsoria Pay — 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 : payouts, lots,
# bénéficiaires, règlements, pagination par curseur. Rien de tout cela n'est
# implémenté à ce jour. 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.
#
# 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: Valsoria Pay — 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 `Valsoria-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.

    **Ce qui n'existe pas encore** : payouts, lots, bénéficiaires, 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: Remboursements
    description: Restitution totale ou partielle d'un encaissement réussi.

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:
            Valsoria-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' } } }
        '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' } } }
        '422':
          description: |
            `invalid_amount`, `invalid_currency`, ou `idempotency_key_reused`
            (même clé, corps différent).
          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-refunds:
    post:
      tags: [Remboursements]
      operationId: creerRemboursement
      summary: Rembourser un encaissement
      description: |
        Rembourse tout ou partie d'un paiement **réussi**. Sans `amount`, le
        reste remboursable est rendu en totalité.

        **Tous les connecteurs ne savent pas rembourser.** Un paiement encaissé
        par un canal sans opération inverse est refusé en `422
        refund_unsupported`, **avant toute écriture** — mieux vaut un refus
        immédiat qu'une promesse qu'on ne tient pas.
      parameters:
        - $ref: '#/components/parameters/CleIdempotence'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_id]
              properties:
                payment_id:
                  type: string
                  pattern: '^txn_'
                  example: txn_6INMt2lIL1Pv_y6RgqZvag
                amount:
                  type: integer
                  minimum: 1
                  description: Absent ⇒ remboursement total du reste.
      responses:
        '201':
          description: Remboursement créé.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Paiement' }
        '409':
          description: |
            `already_refunded`, ou paiement non remboursable dans son état actuel.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }
        '422':
          description: |
            `invalid_payment_id`, `invalid_amount`, `amount_too_large`
            (au-delà du reste remboursable), ou **`refund_unsupported`**.
          content: { application/json: { schema: { $ref: '#/components/schemas/Erreur' } } }

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.

  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.
      schema: { type: string, format: uuid }

  schemas:
    Paiement:
      type: object
      properties:
        id: { type: string, example: txn_6INMt2lIL1Pv_y6RgqZvag }
        object: { type: string, enum: [payment] }
        amount: { type: integer, example: 10000 }
        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 }

    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.
