openapi: 3.1.0

info:
  title: Tupay API
  version: '2026-01-01'
  description: |
    Passerelle de paiement pour la Polynésie française. Encaissement en XPF.

    **Montants** : toujours des entiers XPF. Le franc pacifique n'a pas de
    centimes. `amountEurCents` est exposé en centimes EUR pour la seule
    réconciliation avec le prestataire bancaire ; le XPF fait foi.

    **Modes** : une clé `tpk_test_` opère sur un jeu de données totalement
    étanche au réel. Aucun argent ne bouge, aucun appel n'atteint le
    prestataire bancaire.

    Un premier encaissement de bout en bout : /docs
  contact:
    name: Tupay
  license:
    name: Propriétaire

servers:
  # ⚠️ `api.tupay.pf` N'EST PAS ENREGISTRÉ — le domaine ne résout pas.
  # Il servait de défaut ici, donc chaque exemple copiable de la
  # référence portait une adresse morte : un intégrateur qui collait le
  # curl obtenait « Could not resolve host », soit la pire première
  # impression possible.
  #
  # Le défaut pointe donc l'hôte qui répond réellement. Le jour où
  # `api.tupay.pf` existe, changer cette valeur suffit : elle alimente
  # les exemples, le SDK et la référence par une seule source.
  #
  # Même défaut, déjà corrigé dans `tupay-node/src/client.ts`.
  - url: https://{domain}/api/v1
    variables:
      domain:
        default: tupay.apps.lepetittahitien.dev

security:
  - ApiKey: []

tags:
  - name: Paiements
  - name: Liens de paiement
  - name: Enregistrements de carte
    description: |
      Enregistrer une carte à l'inscription pour prélever plus tard, sans
      rien redemander au porteur.

      **Aucun montant n'est en jeu.** Ni la requête, ni l'objet, ni la
      table ne portent de somme : il est structurellement impossible de
      débiter par ce chemin.
  - name: Moyens de paiement
    description: |
      Enregistrer une carte pour ne pas la redemander.

      **Tupay ne reçoit jamais de numéro de carte.** La carte est
      tokenisée dans le navigateur du porteur, chez le prestataire, sur
      son domaine à lui ; vous ne nous transmettez que le jeton obtenu.
      C'est ce qui maintient votre intégration — et la nôtre — dans le
      périmètre PCI-DSS SAQ A, le plus léger.
  - name: Catalogue
    description: |
      Déclarer une fois ce que vous vendez, puis encaisser en pointant
      l'article plutôt qu'en recopiant libellé et montant.

      Un article se **désactive**, il ne se supprime pas : l'état est
      réversible, et ses tarifs existants continuent de fonctionner.
  - name: Reçus
    description: |
      La preuve de paiement envoyée au **client final**, pas à vous.

      Elle part toute seule dès qu'un paiement aboutit avec une adresse
      connue — celle de la fiche client rattachée, ou celle saisie dans
      le tunnel. Vous n'avez rien à déclencher.

      Ces routes sont le **journal** : à quelle adresse, quand, et ce qui
      a échoué. Un envoi qui rate ne remet jamais un encaissement en
      cause.
  - name: Sessions de paiement
    description: |
      Une page de paiement hébergée par Tupay, à laquelle vous envoyez
      votre client. Vous n'affichez ni formulaire, ni montant : la
      session porte les deux.

      **Le total est figé à la création.** Modifier ou désactiver un
      tarif ensuite ne change pas ce que la session affiche — le prix
      montré au client est celui qu'il paiera.

      **Une session ne se règle qu'une fois.** Un second paiement est
      refusé en `409`, y compris pendant que le premier est encore en
      cours chez la banque. La garantie est tenue par la base, pas par
      la route.
  - name: Abonnements
    description: |
      Lier un client à un tarif récurrent, pour encaisser sans y penser.

      **Un abonnement condamné est refusé à la création.** Un client sans
      moyen de paiement, ou un tarif ponctuel, valent une erreur qui
      nomme la cause — plutôt qu'un abonnement qui échouera à sa
      première échéance, du côté du client.
  - name: Factures
    description: |
      Émises automatiquement à l'échéance d'un abonnement, et prélevées
      dans la foulée sur la carte enregistrée.

      **Les montants sont TTC.** La TGC y est comprise et non ajoutée :
      `htXpf + tgcXpf` vaut exactement `amountXpf`, toujours, et la base
      le vérifie.

      **Une facture émise ne se réécrit pas.** Elle conserve le taux et
      les montants qu'elle portait, même si vous changez de régime
      demain.

      **Un prélèvement refusé est relancé** selon un barème en jours —
      1 j → 3 j → 5 j → 7 j, cinq tentatives sur seize jours. Chacune est
      journalisée. Au bout, la facture passe en souffrance et
      l'abonnement avec ; nous ne résilions jamais à votre place.
  - name: Clients
    description: |
      Le carnet d'adresses du marchand : reconnaître une personne d'un
      paiement à l'autre plutôt que de traiter chaque encaissement comme
      un inconnu de passage.
  - name: Transactions
  - name: Remboursements
  - name: Webhooks
  # Ce tag était UTILISÉ par /events, /events/{id} et /events/{id}/resend
  # sans jamais être déclaré ici. L'ordre du bloc `tags:` est la décision
  # éditoriale qui range la référence publique : un tag absent, c'est une
  # ressource reléguée en fin de sommaire par accident.
  - name: Événements
    description: |
      Ce que Tupay a émis, indépendamment de ce qui a été livré.
  - name: Clés API
  - name: Marchand
  - name: Mode test
  - name: Bac à sable
    description: |
      Obtenir une clé de test sans inscription ni validation humaine.
  - name: Terminal
    description: |
      Encaissement au comptoir depuis l'application mobile Tupay.

      **Ces routes n'acceptent pas les clés API.** Elles sont authentifiées
      par la session du marchand ; cf. le schéma de sécurité `SessionCookie`.

paths:
  /payments/intent:
    post:
      tags: [Paiements]
      summary: Créer un paiement
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amountXpf]
              properties:
                amountXpf:
                  type: integer
                  minimum: 100
                  maximum: 10000000
                  examples: [4500]
                merchantId:
                  type: string
                  format: uuid
                  description: |
                    Facultatif : le marchand est déduit de la clé API.
                    Fourni, il doit correspondre, sinon 403.
                paymentLinkId:
                  type: string
                  format: uuid
                paymentMethod:
                  type: string
                  examples: ['pm_1f9c3d2a-7b4e-4a19-8c05-2d6f8b1e4a73']
                  description: |
                    Carte enregistrée à débiter — `<uuid>` ou `pm_<uuid>`.
                    **Exige `customer`** : sans lui, rien ne permet de
                    vérifier que la carte appartient bien à ce client.

                    Le débit est tenté dans CE MÊME appel : la réponse
                    porte `status: succeeded`, ou un `402` si la banque
                    refuse. Un moyen détaché vaut `409`.
                offSession:
                  type: boolean
                  description: |
                    Le porteur n'est pas devant son écran (abonnement,
                    relance). N'a de sens qu'avec `paymentMethod`.

                    Hors session, une authentification forte ne peut pas
                    aboutir : la banque rend alors
                    `failureCode: authentication_required`, et c'est à
                    vous de ramener le porteur devant son écran.
                customer:
                  type: string
                  examples: ['cus_8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4']
                  description: |
                    Client à rattacher — `<uuid>` ou `cus_<uuid>`. La
                    transaction portera `customerId`, et `expand[]=customer`
                    la développera.

                    Un identifiant inconnu, ou appartenant à un autre
                    marchand, rend `404` **avant tout appel au prestataire** :
                    aucune intention n'est ouverte chez lui, rien n'est à
                    annuler. Un client supprimé reste acceptable — la
                    suppression ferme la modification de la fiche, pas son
                    usage.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Paiement créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      object: { type: string, const: payment_intent }
                      id:
                        type: string
                        format: uuid
                        description: |
                          Identifiant Tupay, à conserver : c'est celui que vous
                          retrouverez sur les remboursements, les webhooks et
                          l'export comptable. Tupay n'a pas de ressource
                          « intent » distincte de la transaction, les deux
                          partagent donc cet identifiant.
                      created:
                        type: integer
                        description: Secondes Unix. Même instant que `createdAt`.
                      livemode: { type: boolean }
                      clientSecret: { type: [string, 'null'] }
                      status:
                        type: string
                        enum: [pending, processing, succeeded, failed, refunded]
                        description: |
                          `pending` pour un paiement ordinaire, qui attend
                          le navigateur. `succeeded` pour un débit sur
                          carte enregistrée, qui aboutit dans cet appel.
                      customerId:
                        type: [string, 'null']
                        format: uuid
                        description: Client rattaché, `null` pour un encaissement de passage.
                      amountXpf: { type: integer }
                      amountEurCents: { type: integer }
                      metadata: { $ref: '#/components/schemas/Metadata' }
                      intentId:
                        type: string
                        deprecated: true
                        description: |
                          Identifiant chez le prestataire bancaire. Observable,
                          jamais stable : il ne fait pas partie du contrat.
                          Servi au minimum douze mois, puis retiré par une
                          version datée.
                      transactionId:
                        type: string
                        format: uuid
                        deprecated: true
                        description: |
                          Doublon de `id` et de même valeur. Servi au minimum
                          douze mois, puis retiré par une version datée.
                          Lisez `id`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '402':
          description: |
            **La banque a refusé le débit.** Elle a répondu, et la réponse
            est non — rien n'est en panne, et retenter à l'identique
            reproduira le refus.

            `details.failureCode` porte la raison précise, et
            `details.transactionId` la transaction, qui EXISTE et porte
            la même raison : un refus est un résultat, pas une requête
            perdue.

            ⚠️ `authentication_required` n'est pas un refus bancaire : le
            porteur doit valider une authentification forte. Même statut
            HTTP, suite différente.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      failureCode:
                        type: string
                        enum: [card_declined, expired_card, incorrect_cvc, insufficient_funds, processing_error, authentication_required]
                      transactionId: { type: string, format: uuid }
              examples:
                refus_bancaire:
                  summary: |
                    La banque a dit non. Reprise : demandez un autre moyen
                    de paiement — retenter à l'identique reproduira le refus.
                  value:
                    error: payment_declined
                    type: provider
                    message: >-
                      La banque du porteur a refusé la carte, sans en donner la
                      raison. Invitez-le à en essayer une autre ou à contacter
                      sa banque.
                    code: 402
                    failureCode: card_declined
                    transactionId: 3c7a1f92-5b4e-4d08-9a61-7e2b0c5d8f34
                authentification_requise:
                  summary: |
                    **Pas un refus.** Le porteur est solvable, il lui manque
                    un geste. Reprise : ramenez-le devant son écran pour qu'il
                    valide son authentification forte. Le traiter comme un
                    refus fait abandonner une vente qui allait aboutir.
                  value:
                    error: payment_declined
                    type: provider
                    message: >-
                      La banque exige une authentification forte (3-D Secure)
                      que le porteur n’a pas menée à son terme. Ce n’est PAS un
                      refus : ramenez-le devant son écran pour qu’il la valide.
                    code: 402
                    failureCode: authentication_required
                    transactionId: 3c7a1f92-5b4e-4d08-9a61-7e2b0c5d8f34
        '429': { $ref: '#/components/responses/RateLimited' }
        '502': { $ref: '#/components/responses/BaasError' }

  /payment-links:
    get:
      tags: [Liens de paiement]
      summary: Lister les liens de paiement
      parameters:
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PaymentLink' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Liens de paiement]
      summary: Créer un lien de paiement
      description: |
        L'en-tête `Idempotency-Key` est facultatif ici. Fourni, un appel
        rejoué rend le lien déjà créé au lieu d'en créer un second, avec un
        second slug.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Fournissez `price` **ou** `amountXpf`, exactement l'un des
                deux. Un lien adossé à un tarif en hérite le montant et le
                libellé ; un lien à montant libre les porte lui-même.
              properties:
                price:
                  type: string
                  # Pas d'`examples` ici, à dessein : le générateur
                  # d'exemples construit UN corps par opération, à partir
                  # de tous les champs qui en portent un. Avec `price`,
                  # il produisait un corps portant aussi `amountXpf` —
                  # c'est-à-dire la requête que l'API refuse. La porte
                  # des exemples Node l'a attrapé : la référence
                  # montrait un exemple copiable et invalide.
                  #
                  # L'exemple copiable reste donc celui du montant libre,
                  # le cas majoritaire. La forme adossée à un tarif est
                  # décrite ci-dessous et dans docs/api-integration.md.
                  description: |
                    Tarif dont le lien hérite. Le montant et le libellé
                    viennent du tarif — le libellé est son `nickname`, ou
                    à défaut le nom de l'article.

                    `description` n'est alors **pas acceptée** : deux
                    textes différents décriraient la même vente.

                    Un tarif désactivé vaut `409` : il ne sert plus à
                    créer de nouveaux liens. Ceux qui le référencent déjà
                    continuent de fonctionner.
                amountXpf:
                  type: integer
                  minimum: 100
                  examples: [4500]
                  description: Pour un lien à montant libre. Exclusif de `price`.
                description:
                  type: string
                  maxLength: 280
                  examples: ['Location paddle 2 h']
                  description: Requise avec `amountXpf`, refusée avec `price`.
                returnUrl:
                  type: string
                  format: uri
                  maxLength: 2000
                  examples: ['https://ma-boutique.pf/merci']
                  description: |
                    Où renvoyer le client après paiement. HTTPS obligatoire
                    en mode live. Tupay y ajoute `tupay_transaction` et
                    `tupay_status`.

                    ⚠️ Ces paramètres transitent par le navigateur du client
                    et sont donc falsifiables. Ne validez jamais une commande
                    dessus : seul le webhook signé fait foi.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Lien créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /payment-links/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Liens de paiement]
      summary: Détail d'un lien
      responses:
        '200':
          description: Lien
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentLink' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Liens de paiement]
      summary: Activer ou désactiver un lien
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active: { type: boolean, examples: [false] }
      responses:
        '200':
          description: Lien mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentLink' }
        '404': { $ref: '#/components/responses/NotFound' }

  /customers:
    get:
      tags: [Clients]
      summary: Lister les clients
      description: |
        Les clients supprimés ne figurent pas dans la liste. Ils restent
        lisibles un par un : cf. `GET /customers/{id}`.
      parameters:
        - name: email
          in: query
          description: |
            Ne rend que les clients portant cette adresse, comparée sans
            tenir compte de la casse.

            Plusieurs fiches peuvent légitimement partager une adresse —
            un couple, une boîte de service, une refacturation. Tupay
            n'impose aucune unicité : ce filtre rend une collection, pas
            un objet.
          schema: { type: string, examples: ['client@exemple.pf'] }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Customer' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Clients]
      summary: Créer un client
      description: |
        Aucun champ n'est obligatoire pris isolément — un commerçant de
        comptoir ne connaît parfois qu'un prénom — mais un corps
        entièrement vide est refusé : une fiche sans la moindre
        information n'identifie personne.

        L'en-tête `Idempotency-Key` est facultatif. Fourni, un appel
        rejoué rend le client déjà créé au lieu d'en créer un second.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 320
                  examples: ['client@exemple.pf']
                name: { type: string, maxLength: 280, examples: ['Teiva Tehei'] }
                phone:
                  type: string
                  maxLength: 40
                  examples: ['+689 87 12 34 56']
                  description: |
                    Conservé tel que vous l'écrivez : ni normalisé, ni
                    validé au-delà de sa longueur. Un marchand de Papeete
                    enregistre aussi des numéros métropolitains ou
                    néo-zélandais, qu'une validation stricte refuserait.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Client créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
  /customers/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Clients]
      summary: Détail d'un client
      description: |
        Répond aussi pour un client supprimé, qui porte alors
        `deleted: true`. C'est délibéré : vos paiements passés le
        désignent, et un `404` rendrait votre historique illisible.
      responses:
        '200':
          description: Client
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Clients]
      summary: Modifier un client
      description: |
        Trois états par champ, à ne pas confondre :

        | Ce que vous envoyez | Effet |
        |---|---|
        | champ absent | inchangé |
        | `"valeur"` | remplacé |
        | `null` | effacé |

        Une chaîne vide est refusée : `""` et « ce champ n'existe pas »
        sont deux états distincts, et les confondre en silence ferait
        dépendre le résultat d'un détail de votre sérialisation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: [string, 'null'], format: email, maxLength: 320 }
                name: { type: [string, 'null'], maxLength: 280 }
                phone: { type: [string, 'null'], maxLength: 40 }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Client mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Le client est supprimé. Sa fiche reste lisible, elle n'est
            plus modifiable — `conflict`, et non `not_found` : dire
            « introuvable » serait un mensonge qu'un simple `GET`
            réfute.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [Clients]
      summary: Supprimer un client
      description: |
        Marque la fiche supprimée **sans rien effacer**. Elle quitte la
        liste, refuse toute modification, et reste lisible : vos
        transactions passées continuent de désigner un client qui
        existe.

        Rejouable sans clé d'idempotence — un second appel rend le même
        objet, avec la même date de suppression.
      responses:
        '200':
          description: Client supprimé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }

  /payment-methods:
    get:
      tags: [Moyens de paiement]
      summary: Lister les moyens de paiement
      description: |
        Les moyens détachés ne figurent pas dans la liste. Ils restent
        lisibles un par un : cf. `GET /payment-methods/{id}`.
      parameters:
        - name: customer
          in: query
          description: |
            N'obtenir que les moyens de ce client — `<uuid>` ou
            `cus_<uuid>`.
          schema: { type: string }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PaymentMethod' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Moyens de paiement]
      summary: Rattacher un moyen de paiement à un client
      description: |
        ⚠️ **Cette route n'accepte aucune donnée de carte.**

        Tokenisez la carte dans le navigateur du porteur (Stripe.js), puis
        transmettez le jeton obtenu dans `token`. Un corps portant
        `number`, `cvc`, `expMonth` ou tout autre champ de carte est
        refusé en `400`, en nommant le champ fautif — le numéro ne doit
        jamais atteindre nos serveurs, ni les vôtres.

        En **mode test**, employez les jetons du bac à sable :
        `pm_card_visa`, `pm_card_mastercard`, `pm_card_amex`,
        `pm_card_expired`. Ils sont déterministes.

        Le **premier** moyen d'un client devient son défaut sans que vous
        ayez à le demander.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer, token]
              properties:
                customer:
                  type: string
                  examples: ['cus_8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4']
                  description: |
                    Client auquel rattacher le moyen. Un client supprimé
                    est refusé en `409` : préparer un prélèvement sur
                    quelqu'un que vous avez déclaré parti n'a pas d'usage.
                token:
                  type: string
                  maxLength: 500
                  examples: ['pm_card_visa']
                  description: |
                    Jeton du prestataire, obtenu côté navigateur. **Jamais
                    un numéro de carte** : une valeur qui y ressemble est
                    refusée avec un message qui l'explique.
                default:
                  type: boolean
                  description: Désigne ce moyen comme celui à utiliser par défaut.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Moyen rattaché
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentMethod' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /payment-methods/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Moyens de paiement]
      summary: Détail d'un moyen de paiement
      description: |
        Répond aussi pour un moyen détaché, qui porte alors
        `detached: true` : vos paiements passés le désignent.
      responses:
        '200':
          description: Moyen de paiement
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentMethod' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Moyens de paiement]
      summary: Désigner par défaut, ou modifier les métadonnées
      description: |
        Seul `default: true` est accepté. Retirer le défaut sans en
        désigner un autre laisserait le client sans moyen par défaut, et
        le prochain paiement qui n'en précise pas échouerait — sans que
        personne l'ait demandé. Pour retirer un moyen, détachez-le.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                default: { type: boolean, const: true }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Moyen mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentMethod' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Le moyen est détaché. Il reste lisible, il n'est plus
            modifiable — `conflict`, et non `not_found` : dire
            « introuvable » serait un mensonge qu'un simple `GET` réfute.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [Moyens de paiement]
      summary: Détacher un moyen de paiement
      description: |
        Le moyen cesse d'être utilisable **sans que rien ne soit effacé**.
        Il quitte la liste, perd son statut de défaut, et reste lisible :
        les paiements qu'il a servis le désignent, et un relevé qui
        perdrait « Visa •4242 » deviendrait illisible.

        Rejouable : un second appel rend le même objet sans rien réécrire.
      responses:
        '200':
          description: Moyen détaché
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PaymentMethod' }
        '404': { $ref: '#/components/responses/NotFound' }

  /setup-intents:
    get:
      tags: [Enregistrements de carte]
      summary: Lister les enregistrements
      parameters:
        - name: customer
          in: query
          description: N'obtenir que ceux de ce client — `<uuid>` ou `cus_<uuid>`.
          schema: { type: string }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SetupIntent' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Enregistrements de carte]
      summary: Ouvrir un enregistrement de carte
      description: |
        Rend un `clientSecret` que **le navigateur du porteur** emploie
        pour saisir sa carte directement chez le prestataire. Le numéro
        ne passe ni par vos serveurs, ni par les nôtres.

        ⚠️ **`clientSecret` n'est servi qu'ICI.** Ni la liste ni le détail
        ne le rendent : un secret relisible à volonté, avec n'importe
        quelle clé, n'en est plus un. Transmettez-le au navigateur à la
        création, ou rouvrez un enregistrement.

        **Aucun montant n'est accepté ni débité.** Pour encaisser, c'est
        `POST /payments/intent`.

        En **mode test**, l'issue se pilote avec
        `POST /test/simulate` : `{ "setupIntentId": "…", "outcome":
        "succeeded", "card": "pm_card_visa" }`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer]
              properties:
                customer:
                  type: string
                  examples: ['cus_8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4']
                  description: |
                    Client pour lequel enregistrer la carte. Un client
                    supprimé est refusé en `409`.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Enregistrement ouvert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/SetupIntent'
                      - type: object
                        properties:
                          clientSecret:
                            type: string
                            description: |
                              À transmettre au navigateur du porteur.
                              **Servi une seule fois**, à la création.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '502': { $ref: '#/components/responses/BaasError' }
  /setup-intents/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Enregistrements de carte]
      summary: Statut d'un enregistrement
      description: |
        Sur un échec, la réponse porte **deux** champs :

        - `failureCode` — un code machine STABLE, sur lequel écrire un
          `if` sans jamais lire le message ;
        - `failureMessage` — une phrase **en français**, montrable telle
          quelle à votre client.

        ⚠️ `clientSecret` n'est **pas** rendu ici.
      parameters:
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Enregistrement
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/SetupIntent' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products:
    get:
      tags: [Catalogue]
      summary: Lister le catalogue
      description: |
        Montre **tout** par défaut, actifs et inactifs. C'est `?active=`
        qui restreint — sans quoi un article retiré le mois dernier
        semblerait avoir disparu.
      parameters:
        - name: active
          in: query
          description: |
            `true` ou `false`. Toute autre valeur vaut `400` : une valeur
            interprétée rendrait une liste vide qu'aucun message
            n'expliquerait.
          schema: { type: boolean }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Catalogue]
      summary: Déclarer un article
      description: |
        Seul `name` est requis. Deux articles peuvent porter le même nom —
        « Chambre double » dans deux pensions, « Sortie lagon » à deux
        horaires : aucune unicité n'est imposée.

        L'en-tête `Idempotency-Key` est facultatif. Fourni, un appel
        rejoué rend l'article déjà créé au lieu d'en créer un second.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 280
                  examples: ['Sortie lagon']
                description:
                  type: string
                  maxLength: 2000
                  examples: ['Deux heures, palmes fournies.']
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Article créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
  /products/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Catalogue]
      summary: Détail d'un article
      description: |
        Répond aussi pour un article désactivé — c'est même le seul moyen
        de le retrouver pour le réactiver.
      responses:
        '200':
          description: Article
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Catalogue]
      summary: Modifier, activer ou désactiver un article
      description: |
        **Il n'y a pas de `DELETE`, et c'est délibéré.** Un article retiré
        de la vente peut y revenir — une pension retire son offre de
        saison en avril et la remet en octobre. `active: false` dit
        exactement cela, et `active: true` le défait.

        Désactiver ne détruit rien : les tarifs qui référencent l'article
        continuent de fonctionner.

        `description: null` efface le champ ; l'omettre le laisse
        inchangé. `name` n'est pas effaçable — un article sans nom
        n'existe pas.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 280 }
                description: { type: [string, 'null'], maxLength: 2000 }
                active: { type: boolean, examples: [false] }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Article mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /checkout-sessions:
    get:
      tags: [Sessions de paiement]
      summary: Lister les sessions de paiement
      description: |
        ⚠️ Une session `open` dont l'échéance est passée peut encore
        apparaître ici : l'expiration est matérialisée à la **lecture
        unitaire**, pas par cette liste — une liste ne doit pas écrire.
        `GET /checkout-sessions/{id}` rend l'état à jour.
      parameters:
        - name: status
          in: query
          description: '`open`, `complete` ou `expired`.'
          schema: { type: string, enum: [open, complete, expired] }
        - name: customerMatch
          in: query
          description: |
            Comment la fiche client a été obtenue au règlement.
            `ambiguous` est la liste à ouvrir pour réconcilier.
          schema: { type: string, enum: [provided, created, unique, ambiguous] }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CheckoutSession' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Sessions de paiement]
      summary: Ouvrir une session de paiement
      description: |
        Rend une `url` hébergée par Tupay. Redirigez-y votre client :
        c'est nous qui affichons le récapitulatif et encaissons.

        **Le total est calculé et figé maintenant.** Chaque ligne porte
        soit un `price` du catalogue — dont elle hérite montant et
        libellé — soit un `amountXpf` et une `description` libres. Un
        tarif désactivé vaut `409` : il ne sert plus à composer de
        nouvelles offres.

        La session vit 24 heures par défaut, `expiresInMinutes` entre 30
        et 1440.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lineItems, successUrl]
              properties:
                lineItems:
                  type: array
                  minItems: 1
                  maxItems: 100
                  examples:
                    - - price: 'price_3fa85f64-5717-4562-b3fc-2c963f66afa6'
                        quantity: 2
                  items:
                    type: object
                    description: |
                      `price` **ou** `amountXpf`, exactement l'un des
                      deux. Avec `price`, le libellé vient du tarif et
                      `description` est refusée ; sans lui, elle est
                      exigée.
                    properties:
                      price:
                        type: string
                        description: '`<uuid>` ou `price_<uuid>`.'
                      description: { type: string, maxLength: 280 }
                      amountXpf:
                        type: integer
                        minimum: 1
                        maximum: 100000000
                        description: Entier XPF. Le franc pacifique n'a pas de centimes.
                      quantity: { type: integer, minimum: 1, maximum: 1000, default: 1 }
                successUrl:
                  type: string
                  maxLength: 2000
                  examples: ['https://boutique.pf/merci']
                  description: Où renvoyer le client une fois payé. HTTPS exigé en mode live.
                cancelUrl: { type: string, maxLength: 2000 }
                expiresInMinutes:
                  type: integer
                  minimum: 30
                  maximum: 1440
                  default: 1440
                customer:
                  type: string
                  description: '`<uuid>` ou `cus_<uuid>`.'
                customerEmail:
                  type: string
                  format: email
                  maxLength: 320
                  description: |
                    Constitue votre fichier client tout seul : au
                    règlement, la fiche est retrouvée ou créée, et le
                    résultat est lisible dans `customerMatch`. Ignoré si
                    `customer` est fourni.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Session ouverte
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CheckoutSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Un tarif référencé est désactivé. Créez-en un neuf, ou
            réactivez celui-ci.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /checkout-sessions/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Sessions de paiement]
      summary: Détail d'une session de paiement
      description: |
        C'est ici que l'expiration est **matérialisée** : une session
        `open` dont l'échéance est passée est rendue `expired`, et l'est
        désormais pour tout le monde.

        Aucune tâche périodique n'en est chargée — un travail planifié
        qui ne tourne pas laisserait des sessions payables au-delà de
        leur heure.
      parameters:
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Session
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CheckoutSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /receipts:
    get:
      tags: [Reçus]
      summary: Lister les reçus envoyés à vos clients
      description: |
        Le journal des preuves de paiement. Un reçu par paiement abouti
        portant une adresse — jamais deux pour le même encaissement.

        La ligne à surveiller est `status: failed` ou `exhausted` : votre
        client n'a rien reçu, et vous êtes le seul à pouvoir le savoir.
      parameters:
        - name: status
          in: query
          description: |
            `pending` en attente d'envoi, `succeeded` parti,
            `failed` en échec avec une reprise prévue,
            `exhausted` abandonné — à rejouer vous-même.
          schema: { type: string, enum: [pending, succeeded, failed, exhausted] }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Receipt' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /receipts/{id}/resend:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags: [Reçus]
      summary: Remettre un reçu en file
      description: |
        Le rejeu automatique s'arrête au bout de sept tentatives
        (~34 h). Passé ce délai, vous seul savez si la cause a disparu —
        adresse corrigée, domaine rétabli.

        **Aucun second reçu n'est créé.** La ligne existante repart à
        zéro tentative : votre client ne doit pas recevoir deux preuves
        pour un même paiement.

        Un reçu déjà parti vaut `409` : le renvoyer ferait douter votre
        client d'un second débit.
      responses:
        '200':
          description: Reçu remis en file
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Receipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Ce reçu est déjà parti.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /subscriptions:
    get:
      tags: [Abonnements]
      summary: Lister les abonnements
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [active, paused, canceled] }
        - name: customer
          in: query
          description: N'obtenir que les abonnements de ce client.
          schema: { type: string }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Abonnements]
      summary: Créer un abonnement
      description: |
        Lie un client à un tarif **récurrent**. La période courante part
        de maintenant et sa fin est la prochaine échéance, calculée
        depuis la périodicité du tarif.

        **Deux refus, et ils sont voulus.** Un abonnement qui ne peut pas
        aboutir n'est pas créé :

        | Situation | Réponse |
        |---|---|
        | le client n'a aucune carte enregistrée | `409` — il échouerait à la première échéance |
        | le tarif est **ponctuel** | `400` — il prélèverait indéfiniment pour un achat unique |
        | le tarif est désactivé | `409` — les abonnements en cours continuent |

        Sans `paymentMethod`, la carte **par défaut** du client est
        retenue. Une carte détachée n'est jamais retenue.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer, price]
              properties:
                customer:
                  type: string
                  examples: ['cus_3fa85f64-5717-4562-b3fc-2c963f66afa6']
                  description: '`<uuid>` ou `cus_<uuid>`.'
                price:
                  type: string
                  examples: ['price_8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4']
                  description: |
                    `<uuid>` ou `price_<uuid>`. **Doit être récurrent.**
                paymentMethod:
                  type: string
                  description: |
                    `<uuid>` ou `pm_<uuid>`. Absent, la carte par défaut
                    du client est retenue.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Abonnement créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Le client n'a aucun moyen de paiement, ou le tarif est
            désactivé. La cause est nommée dans le message.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /subscriptions/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Abonnements]
      summary: Détail d'un abonnement
      parameters:
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Abonnement
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /subscriptions/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags: [Abonnements]
      summary: Annuler un abonnement
      description: |
        `{ }` — il cesse **à l'instant**.
        `{ "atPeriodEnd": true }` — le client garde son service jusqu'à
        l'échéance, puis l'abonnement se ferme sans nouvelle facture.

        Le défaut est l'annulation immédiate : un marchand qui doit
        couper un abonnement d'urgence ne doit pas le voir courir un mois
        de plus par inadvertance.

        ### Proratisation

        **Aucune.** La période en cours n'est pas remboursée, et la
        réponse le dit dans `proration`. Rembourser automatiquement
        reviendrait à déplacer de l'argent sur une décision prise par une
        machine ; `POST /refunds` laisse ce geste au marchand.

        Un abonnement déjà annulé vaut `409` : il ne repart pas, on en
        crée un nouveau.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                atPeriodEnd:
                  type: boolean
                  default: false
                  description: Laisser courir le service jusqu'à l'échéance.
      responses:
        '200':
          description: Abonnement annulé, ou annulation programmée
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Subscription'
                      - type: object
                        properties:
                          proration:
                            type: object
                            description: La règle appliquée, toujours explicitée.
                            properties:
                              behavior: { type: string, enum: [none] }
                              description: { type: string }
                          effect:
                            type: string
                            description: Ce qu'il y a à dire au client, en une phrase.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: L'abonnement est déjà annulé.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /subscriptions/{id}/pause:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags: [Abonnements]
      summary: Suspendre un abonnement
      description: |
        Aucune facture n'est émise et aucun prélèvement n'est tenté tant
        que l'abonnement est en pause, échéance atteinte ou non.

        **L'échéance n'est pas repoussée.** Reprendre après une longue
        pause fera donc facturer au passage suivant : c'est vous qui
        décidez de la durée, nous ne décalons pas une date que vous avez
        annoncée à votre client.

        Rejouer l'appel sur un abonnement déjà en pause rend simplement
        son état.
      responses:
        '200':
          description: Abonnement suspendu
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: L'abonnement est annulé — il ne repart pas.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /subscriptions/{id}/resume:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags: [Abonnements]
      summary: Reprendre un abonnement suspendu
      description: |
        Un abonnement **annulé** ne repart jamais : `409`, et il faut en
        créer un nouveau. Faire repartir un prélèvement que le client
        croyait arrêté, sans qu'il ait rien signé, serait le pire des
        services rendus.
      responses:
        '200':
          description: Abonnement repris
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: L'abonnement est annulé — il ne repart pas.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /balance:
    get:
      tags: [Argent]
      summary: Votre solde
      description: |
        Ce que vous avez, et ce qui arrive.

        `available` est encaissable ; `pending` ne l'est pas encore. La
        différence est une **date** : chaque mouvement porte l'instant où
        il bascule, figé au moment où il est écrit. Rien ne le recalcule
        à l'affichage.

        Le solde n'a ni identifiant ni date de création : c'est un état
        instantané, pas un objet stocké. Ce sont les **écritures** qui en
        ont.

        ### La TGC apparaît à part

        Vos montants sont TTC. Un solde brut contiendrait donc la TGC que
        vous devez à la DICP, et que vous risqueriez de dépenser.
        `tgcXpf` vous dit combien, et `netOfTgcXpf` ce qu'il vous reste
        une fois cette taxe mise de côté.

        ⚠️ **C'est une indication, pas une déclaration.** Nous appliquons
        le taux déclaré sur votre compte. Si vous vendez à plusieurs
        taux, ce chiffre ne peut pas être exact — votre comptable, lui,
        le sera.

        ### Les frais sont déjà déduits

        `amountXpf` est net de commission. Le détail — brut, frais, net —
        se lit sur chaque écriture.

        ⚠️ Tant qu'aucun acquéreur n'est branché, les délais de
        disponibilité sont ceux d'une estimation : J+2 ouvrés. La
        mécanique est réelle ; le calendrier sera celui du contrat.
      responses:
        '200':
          description: Le solde, à l'instant de l'appel
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Balance' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /balance-transactions:
    get:
      tags: [Argent]
      summary: Le journal de votre solde
      description: |
        Une ligne par mouvement : paiement, remboursement, et — à
        mesure qu'ils arriveront — virement et litige.

        Chaque écriture porte `grossXpf`, `feeXpf` et `netXpf`. Le net
        suffirait à additionner ; les trois permettent d'**expliquer**.

        ### Le journal s'additionne

        La somme des `netXpf` de toutes vos écritures est exactement
        votre solde. Celles dont `availableAt` est passé forment
        `available`, les autres `pending`.

        ### Ce que vous ne pouvez pas faire

        Ni créer, ni modifier, ni supprimer une écriture. Il n'existe
        aucune route pour cela, et la base refuse de son côté : une
        écriture comptable est immuable.

        Une correction s'écrit en **ajustement** — une ligne de plus, qui
        laisse la première visible. C'est ce que fait une comptabilité.

        ### Ce qui est figé sur chaque ligne

        `tgcRate` et le couple `feeBps` / `feeFixedXpf` sont copiés au
        moment de l'écriture. Changer de régime de TGC ou renégocier
        votre tarif ne réécrit pas votre passé.
      parameters:
        - name: type
          in: query
          description: N'obtenir qu'un type de mouvement.
          schema:
            type: string
            enum: [payment, refund, fee, payout, dispute, adjustment]
        - name: available
          in: query
          description: |
            `true` pour ce qui est encaissable, `false` pour ce qui
            attend. La bascule est une comparaison de dates, pas un
            statut : rien n'a besoin de tourner pour qu'elle ait lieu.
          schema: { type: boolean }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Le journal, page par page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/BalanceTransaction' }
                  hasMore: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /invoices:
    get:
      tags: [Factures]
      summary: Lister les factures
      description: |
        Les lignes sont rendues **d'office**, jamais sur demande : une
        facture sans ses lignes n'est pas une facture.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [open, paid, past_due, void] }
        - name: customer
          in: query
          schema: { type: string }
        - name: subscription
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /invoices/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Factures]
      summary: Détail d'une facture
      parameters:
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Facture
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Invoice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /invoices/{id}/document:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Factures]
      summary: La facture, imprimable
      description: |
        Rend la facture comme un **document**, pas comme du JSON.

        Le document est en français, montants en francs pacifiques
        entiers, avec la mention de la TGC appliquée. Il porte le
        `number` de la facture — celui qu'on cite à un comptable — et
        non son identifiant technique.

        ### Obtenir un PDF

        Ouvrez le document dans un navigateur et faites
        « Imprimer → Enregistrer en PDF ». La feuille de style prévoit
        une page A4 : les marges, l'en-tête et le tableau sont déjà
        posés.

        Nous ne rendons pas de PDF côté serveur pour l'instant — cela
        demanderait un moteur de rendu dans l'image pour produire
        exactement ce que votre navigateur sait déjà faire.

        ### Une facture de test se voit

        Son numéro commence par `TEST-`, et le document s'ouvre sur la
        mention « document sans valeur comptable ». Elle est en tête,
        jamais en pied de page : une photocopie de la première page doit
        la porter.

        ### Ce que le document ne contient pas encore

        L'identité légale du vendeur — numéro Tahiti, RC, adresse — n'y
        figure pas : Tupay ne la collecte pas encore. Le document dit ce
        qu'il sait et n'invente rien.
      parameters:
        - name: format
          in: query
          description: |
            `html` (défaut) rend le document imprimable ;
            `text` rend la même facture en texte brut, pour une pièce
            jointe ou un journal.
          schema:
            type: string
            enum: [html, text]
            default: html
      responses:
        '200':
          description: |
            Le document. `Content-Disposition: inline`, nommé d'après le
            numéro de la facture.
          content:
            text/html:
              schema: { type: string }
            text/plain:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /invoices/{id}/pay:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags: [Factures]
      summary: Retenter le prélèvement d'une facture
      description: |
        Débite **maintenant**, sans attendre le prochain palier du
        barème de relance.

        Utile quand votre client vient de corriger sa carte et que vous
        ne voulez pas lui faire attendre jusqu'à sept jours.

        ### Ce que la route ne fait pas

        **Elle ne remet pas le compteur à zéro.** Une carte neuve ne
        mérite pas cinq essais de plus si elle échoue aussi — votre
        client verrait défiler dix refus sur son relevé.

        ### Le refus est rendu en `200`

        Un prélèvement refusé n'est pas une panne de l'API : c'est le
        résultat de la tentative. La réponse porte la facture à jour et
        un objet `attempt` qui dit ce qui s'est passé.

        Une facture déjà réglée vaut `409` : la retenter débiterait votre
        client une seconde fois.
      responses:
        '200':
          description: Tentative effectuée, aboutie ou non
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Invoice'
                      - type: object
                        properties:
                          attempt:
                            type: object
                            properties:
                              succeeded: { type: boolean }
                              outcome:
                                type: string
                                enum: [reglee, a_relancer, en_souffrance]
                              failureMessage: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: La facture est déjà réglée, ou annulée.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /prices:
    get:
      tags: [Catalogue]
      summary: Lister les tarifs
      parameters:
        - name: product
          in: query
          description: N'obtenir que les tarifs de cet article — `<uuid>` ou `prod_<uuid>`.
          schema: { type: string }
        - name: active
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/Expand'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Price' }
                  has_more: { type: boolean }
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: Déprécié, doublon de `has_more`. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Catalogue]
      summary: Attacher un prix à un article
      description: |
        Le montant est un **entier de francs pacifiques**. Le XPF n'a pas
        de centimes : 6 000 XPF s'écrit `6000`, jamais `6000.50` ni
        `600000`. Une valeur décimale vaut `400`, avec un message qui
        l'explique.

        Un même article porte plusieurs tarifs — à l'unité et par
        abonnement, par exemple.

        ⚠️ **Un tarif est immuable dès sa création.** Prévoyez de créer
        un tarif neuf plutôt que de corriger celui-ci : cf.
        `PATCH /prices/{id}`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product, amountXpf]
              properties:
                product:
                  type: string
                  examples: ['prod_8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4']
                amountXpf:
                  type: integer
                  minimum: 1
                  maximum: 100000000
                  examples: [6000]
                  description: Entier XPF. Le franc pacifique n'a pas de centimes.
                recurring:
                  type: object
                  description: |
                    Absent = tarif ponctuel. Les deux champs vont
                    ensemble : une périodicité sans nombre décrirait un
                    abonnement dont personne ne sait quand il se prélève.
                  required: [interval]
                  properties:
                    interval:
                      type: string
                      enum: [day, week, month, year]
                    intervalCount:
                      type: integer
                      minimum: 1
                      maximum: 52
                      default: 1
                      description: « tous les 3 mois » = `month` + `3`.
                nickname: { type: string, maxLength: 280, examples: ['Tarif plein'] }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Tarif créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Price' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /prices/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Catalogue]
      summary: Détail d'un tarif
      description: |
        Répond aussi pour un tarif désactivé — c'est souvent celui qu'on
        cherche, pour savoir à combien on vendait avant.
      parameters:
        - $ref: '#/components/parameters/Expand'
      responses:
        '200':
          description: Tarif
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Price' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Catalogue]
      summary: Modifier l'étiquetage d'un tarif
      description: |
        ⚠️ **Le prix ne change jamais.** `amountXpf`, `currency`,
        `recurring` et `product` sont immuables dès la création — pas
        seulement une fois le tarif utilisé.

        Un prix qui change réécrirait vos ventes passées : un rapport
        comptable recalculé six mois plus tard donnerait d'autres
        chiffres pour les mêmes commandes.

        Une tentative vaut `409`, avec la marche à suivre : **créez un
        tarif neuf, puis désactivez celui-ci**. Vos liens et sessions
        existants continueront de fonctionner.

        Ce qui reste modifiable : `nickname`, `active`, `metadata`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nickname: { type: [string, 'null'], maxLength: 280 }
                active: { type: boolean }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Tarif mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Price' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Tentative de changer le prix. Le message nomme les champs
            fautifs et donne la marche à suivre.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /transactions:
    get:
      tags: [Transactions]
      summary: Lister les transactions
      parameters:
        - name: customer
          in: query
          description: |
            N'obtenir que les paiements de ce client — `<uuid>` ou
            `cus_<uuid>`.

            Un identifiant inconnu, ou d'un autre marchand, rend une
            **collection vide** et non `404` : c'est un critère de
            recherche, pas une ressource adressée, et l'URL désigne bien la
            collection des transactions, qui existe. Un identifiant
            malformé, lui, vaut `400`.
          schema: { type: string }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Expand'
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, succeeded, failed, refunded]
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Transaction' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /transactions/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Transactions]
      parameters:
        - $ref: '#/components/parameters/Expand'
      summary: Détail d'une transaction
      responses:
        '200':
          description: Transaction
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Transaction' }
        '404': { $ref: '#/components/responses/NotFound' }

  /transactions/export:
    get:
      tags: [Transactions]
      summary: Exporter les transactions en CSV
      description: CSV séparé par point-virgule, encodé UTF-8 avec BOM (compatible Excel).
      parameters:
        - name: created_after
          in: query
          schema: { type: string, format: date }
        - name: created_before
          in: query
          schema: { type: string, format: date }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, succeeded, failed, refunded]
      responses:
        '200':
          description: Fichier CSV
          content:
            text/csv:
              schema: { type: string }

  /refunds:
    get:
      tags: [Remboursements]
      summary: Lister les remboursements
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Expand'
        - name: transactionId
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Refund' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
    post:
      tags: [Remboursements]
      summary: Rembourser une transaction
      description: |
        `amountXpf` omis = remboursement total du reliquat.

        Les remboursements partiels s'accumulent dans
        `Transaction.refundedAmountXpf`. Le `status` de la transaction ne
        passe à `refunded` qu'au remboursement intégral.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transactionId]
              properties:
                transactionId:
                  type: string
                  format: uuid
                  examples: ['3c7a1f92-5b4e-4d08-9a61-7e2b0c5d8f34']
                amountXpf: { type: integer, minimum: 1 }
                reason: { type: string, maxLength: 200 }
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Remboursement créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Refund' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '502': { $ref: '#/components/responses/BaasError' }

  /webhook-endpoints:
    get:
      tags: [Webhooks]
      summary: Lister les endpoints déclarés
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
    post:
      tags: [Webhooks]
      summary: Déclarer un endpoint
      description: |
        Le `secret` de signature n'est renvoyé qu'ici, **une seule fois**.

        En mode live : HTTPS et port 443 obligatoires, et l'hôte ne doit pas
        résoudre vers une adresse privée. En mode test, `http://localhost`
        est accepté.

        Maximum 5 endpoints actifs par mode.

        L'en-tête `Idempotency-Key` est facultatif ici. Fourni, un appel
        rejoué rend l'endpoint déjà créé au lieu d'en créer un second, avec
        un second secret, jusqu'au plafond. Le rejeu rend alors `secret` à
        `null` : le secret n'est pas conservé avec le corps mémorisé, sinon
        il séjournerait 24 heures en base en clair.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, examples: ['https://ma-boutique.pf/tupay/webhook'] }
                description: { type: string, maxLength: 200 }
                enabledEvents:
                  type: array
                  default: ['*']
                  items:
                    type: string
                    enum: ['*', payment.succeeded, payment.failed, payment.processing, payment.refunded]
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Endpoint créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/WebhookEndpoint'
                      - type: object
                        properties:
                          secret:
                            type: string
                            description: Secret de signature. Affiché une seule fois.
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }

  /webhook-endpoints/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Webhooks]
      summary: Détail d'un endpoint
      responses:
        '200':
          description: Endpoint
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Webhooks]
      summary: Modifier un endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                url: { type: string, format: uri }
                description: { type: [string, 'null'] }
                enabledEvents:
                  type: array
                  items: { type: string }
                status:
                  type: string
                  enum: [enabled, disabled]
                  examples: [disabled]
      responses:
        '200':
          description: Endpoint mis à jour
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Webhooks]
      summary: Désactiver un endpoint
      description: Désactivation logique. L'historique de livraison reste consultable.
      responses:
        '200':
          description: Endpoint désactivé
        '404': { $ref: '#/components/responses/NotFound' }

  /webhook-endpoints/{id}/deliveries:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags: [Webhooks]
      summary: Historique de livraison
      description: L'outil de débogage principal d'une intégration.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Expand'
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, succeeded, failed, exhausted]
        - name: eventType
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /events:
    get:
      tags: [Événements]
      summary: Lister les événements émis
      description: |
        Les événements du marchand, du plus récent au plus ancien.

        Ils existent indépendamment de leurs livraisons : un marchand
        sans endpoint de webhook garde quand même une trace complète de
        son activité.

        Pagination par CURSEUR, pas par décalage. Un `offset` glisse dès
        qu'un événement naît pendant le parcours, on en saute alors
        silencieusement.

        Le mode vient de la clé : une clé de test ne voit jamais les
        événements live, et réciproquement.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - $ref: '#/components/parameters/EndingBefore'
        - name: apres
          in: query
          deprecated: true
          description: |
            Déprécié, renommé `starting_after`. Alias strict : même
            comportement, même validation, deux noms. Reste accepté au
            minimum douze mois.

            Ne se combine pas avec `starting_after` ni `ending_before`.
          schema: { type: string, examples: ['evt_9f8c1a2b3d4e5f60'] }
        - name: type
          in: query
          schema:
            type: string
            enum: [payment.succeeded, payment.failed, payment.processing, payment.refunded]
      responses:
        '200':
          description: Collection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Event' }
                  has_more:
                    type: boolean
                    description: |
                      Vrai s'il reste au moins un élément dans le sens du
                      parcours en cours. Calculé sans `COUNT(*)`.
                  hasMore:
                    type: boolean
                    deprecated: true
                    description: |
                      Déprécié, doublon de `has_more` et de même valeur.
                      Servi au minimum douze mois, puis retiré par une
                      version datée. Lisez `has_more`.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /events/{id}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Événements]
      summary: Lire un événement
      description: |
        Le geste courant du débogage : un webhook arrive, vous tenez son
        `id`, vous voulez revoir l'objet exact que Tupay a émis.

        Un événement d'un autre mode répond `404`, pas `403` : la réponse
        ne révèle pas l'existence d'un événement live à qui tient une clé
        de test.
      responses:
        '200':
          description: L'événement
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /events/{id}/resend:
    parameters:
      - $ref: '#/components/parameters/EventId'
    post:
      tags: [Événements]
      summary: Rejouer un événement
      description: |
        Rejoue un événement vers tous vos endpoints actifs du mode
        courant.

        **Le rejeu CRÉE, il ne modifie pas.** Une nouvelle livraison est
        insérée avec un rang supérieur ; l'ancienne reste intacte comme
        pièce d'audit, et le backoff d'une livraison encore en cours
        n'est pas perturbé.

        L'enveloppe est reconstruite à partir de l'objet stocké et de la
        version **actuelle** de l'endpoint. Un endpoint qui aurait changé
        de version reçoit la forme qu'il attend aujourd'hui, pas celle du
        jour de l'émission.

        ⚠️ Votre gestionnaire recevra l'événement une fois de plus avec
        le même `id` : dédupliquez dessus.

        L'en-tête `Idempotency-Key` est facultatif ici, et il est HONORÉ :
        fourni, deux appels ne créent qu'une seule livraison de plus, et le
        second ne relivre rien. Sans clé, chaque appel crée une livraison
        supplémentaire, ce qui reste le comportement voulu de cette
        opération.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      responses:
        '200':
          description: Livraisons créées
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      object: { type: string, enum: [event_resend] }
                      eventId: { type: string }
                      deliveries:
                        type: integer
                        description: Une par endpoint actif.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: Aucun endpoint actif pour ce mode.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                no_endpoint:
                  summary: Rien à rejouer vers personne. Déclarez un endpoint d'abord.
                  value:
                    error: no_endpoint
                    type: conflict
                    message: Aucun endpoint actif pour ce mode. Déclarez-en un avant de rejouer.
                    code: 422
        '429': { $ref: '#/components/responses/RateLimited' }

  /api-keys:
    get:
      tags: [Clés API]
      summary: Lister les clés du mode courant
      responses:
        '200':
          description: Collection
    post:
      tags: [Clés API]
      summary: Créer une clé
      description: |
        La valeur brute n'est renvoyée qu'ici, **une seule fois** : seul son
        SHA-256 est conservé.

        Une clé de test ne peut pas créer de clé live (403).

        L'en-tête `Idempotency-Key` est facultatif ici. Fourni, un appel
        rejoué rend la clé déjà créée au lieu d'en créer une seconde, jusqu'au
        plafond. Le rejeu rend alors `key` à `null` : la valeur brute n'est
        pas conservée avec le corps mémorisé, sinon elle séjournerait 24
        heures en base en clair.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64, examples: ['Boutique en ligne'] }
                livemode: { type: boolean, default: true }
      responses:
        '201':
          description: Clé créée
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: |
            Corps invalide (`validation_error`), ou plafond de clés actives
            atteint pour ce mode (`limit_reached`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                limit_reached:
                  summary: >-
                    Plafond de clés actives atteint. Reprise : révoquez une
                    clé existante, ne retentez pas à l'identique.
                  value:
                    error: limit_reached
                    type: conflict
                    message: Maximum 5 clés actives autorisées en mode test.
                    code: 422
                validation_error:
                  summary: Corps de requête refusé par le schéma.
                  value:
                    error: validation_error
                    type: invalid_request
                    message: Données invalides.
                    code: 422

  /api-keys/{id}:
    parameters:
      - $ref: '#/components/parameters/ResourceId'
    delete:
      tags: [Clés API]
      summary: Révoquer une clé
      responses:
        '200':
          description: Clé révoquée
        '404': { $ref: '#/components/responses/NotFound' }

  /merchants/me:
    get:
      tags: [Marchand]
      summary: Profil du marchand
      description: |
        `kyb_status` doit valoir `validated` pour encaisser en mode live.
      responses:
        '200':
          description: Profil
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Merchant' }
    patch:
      tags: [Marchand]
      summary: Mettre à jour le profil
      description: |
        **Session dashboard obligatoire.** Cette route refuse les clés API :
        modifier l'IBAN redirige les virements, une clé volée ne doit pas
        pouvoir le faire.

        Tous les champs sont facultatifs. Ceux que vous omettez restent
        inchangés.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 120
                  examples: ['Bijoux Hina']
                iban_xpf:
                  type: string
                  description: IBAN français, espaces tolérées.
                  examples: ['FR76 3000 4000 0312 3456 7890 143']
                tgc_rate:
                  type: string
                  enum: [exempt, '5', '10', '16']
                  description: Taux de TGC applicable à vos encaissements.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Profil mis à jour
        '401': { $ref: '#/components/responses/Unauthorized' }

  /test/simulate:
    post:
      tags: [Mode test]
      summary: Forcer l'issue d'un paiement ou d'un enregistrement de test
      description: |
        **Clé `tpk_test_` exclusivement.** Une clé live ou une session
        dashboard reçoivent 403.

        Fournissez **exactement l'un** des deux identifiants :

        - `transactionId` — met à jour la transaction et émet le webhook
          correspondant ;
        - `setupIntentId` — conclut un enregistrement de carte. Sur
          `succeeded`, la carte désignée par `card` est créée et rattachée
          au client ; sur `failed`, `failureCode` détermine la raison
          rendue par `GET /setup-intents/{id}`.

        Rejouer la même issue ne réémet pas d'événement et **ne crée pas
        une seconde carte** — un webhook livré deux fois est la norme.

        L'en-tête `Idempotency-Key` est facultatif ici.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyFacultative'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outcome]
              properties:
                transactionId:
                  type: string
                  format: uuid
                  examples: ['3c7a1f92-5b4e-4d08-9a61-7e2b0c5d8f34']
                setupIntentId:
                  type: string
                  format: uuid
                  description: |
                    Enregistrement de carte à conclure. Exclusif de
                    `transactionId`.
                outcome:
                  type: string
                  enum: [succeeded, failed, processing]
                  examples: [succeeded]
                  description: |
                    `processing` ne s'applique qu'à un paiement : un
                    enregistrement aboutit ou échoue.
                card:
                  type: string
                  enum: [pm_card_visa, pm_card_mastercard, pm_card_amex, pm_card_expired]
                  description: |
                    La carte enregistrée sur un `succeeded`. Défaut
                    `pm_card_visa`. Sans effet sur un paiement.
                failureCode:
                  type: string
                  enum: [card_declined, expired_card, incorrect_cvc, insufficient_funds, processing_error, authentication_required]
                  description: |
                    La raison rendue sur un `failed`. Défaut
                    `card_declined`. Permet d'éprouver votre affichage
                    pour **chaque** raison, plutôt que d'attendre de les
                    rencontrer en production.
      responses:
        '200':
          description: Transaction ou enregistrement mis à jour
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /sandbox/keys:
    post:
      tags: [Bac à sable]
      summary: Obtenir une clé de test sans inscription
      # Seule route de l'API sans authentification : elle sert précisément
      # à en obtenir une. Surcharge le `security: [ApiKey]` global.
      security: []
      description: |
        Délivre une clé `tpk_test_` immédiatement : ni email, ni mot de
        passe, ni validation humaine. Le KYB vérifie l'identité de qui va
        encaisser de l'argent réel ; il n'a rien à vérifier sur un bac à
        sable où rien n'est encaissé.

        **La clé ne vaut qu'en mode test.** Elle ne peut ni encaisser, ni
        créer une clé live, ni lire une donnée réelle. Le compte associé
        est purgé passé le délai annoncé par le champ `ttlHours` de la réponse.

        Le nombre de clés délivrées est plafonné par adresse IP. Corps de
        requête vide.

        L'en-tête `Idempotency-Key` est IGNORÉ ici. Cette opération n'est pas
        authentifiée : il n'y a pas de compte à qui rattacher la clé, donc
        pas de cloisonnement possible entre appelants. Une clé partagée par
        tous serait une fuite, pas une garantie.
      responses:
        '201':
          description: Clé de test délivrée
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      object: { type: string, const: sandbox_key }
                      apiKey:
                        type: string
                        description: |
                          Valeur brute, renvoyée **une seule fois** : seul son
                          SHA-256 est conservé.
                        examples: ['tpk_test_4f2a9c8e1b6d0a35']
                      merchantId:
                        type: string
                        format: uuid
                        description: Exigé par `POST /payments/intent`.
                      livemode: { type: boolean, const: false }
                      expiresAt:
                        type: string
                        format: date-time
                        description: Purge du compte de démonstration.
                      ttlHours: { type: integer, examples: [24] }
                      quickstart:
                        type: object
                        description: Premiers appels à essayer, dans l'ordre.
                        properties:
                          documentation: { type: string }
                          creerUnLien: { type: string }
                          creerUnPaiement: { type: string }
                          forcerLIssue: { type: string }
        '429':
          description: |
            Deux causes distinctes, deux codes machine, à ne pas confondre :

            - `rate_limit_exceeded` : vous appelez trop **vite**. Quelques
              secondes suffisent.
            - `sandbox_quota_exceeded` : le **plafond de 10 clés par adresse
              IP sur 24 heures glissantes** est atteint. Le corps porte alors
              `reprendAt`, l'instant exact où une nouvelle clé redeviendra
              disponible. Inutile de réessayer avant.
          content:
            application/json:
              schema:
                type: object
                required: [error, message, code]
                properties:
                  error: { type: string, examples: ['sandbox_quota_exceeded'] }
                  message: { type: string }
                  code: { type: integer, examples: [429] }
                  reprendAt:
                    type: string
                    format: date-time
                    description: |
                      Instant à partir duquel une nouvelle clé pourra être
                      obtenue depuis cette adresse. Présent uniquement avec
                      `sandbox_quota_exceeded`.
                    examples: ['2026-08-19T06:30:56.236Z']
              examples:
                sandbox_quota_exceeded:
                  summary: Plafond de clés atteint pour cette adresse IP.
                  value:
                    error: sandbox_quota_exceeded
                    type: rate_limit
                    message: >-
                      Plafond atteint : 10 clés de test par adresse IP sur 24
                      heures glissantes. La plus ancienne se libère le
                      2026-08-19T06:30:56.236Z. Créez un compte permanent pour
                      ne plus dépendre de ce plafond.
                    code: 429
                    reprendAt: '2026-08-19T06:30:56.236Z'
                rate_limit_exceeded:
                  summary: Appels trop rapprochés. Réessayez dans un instant.
                  value:
                    error: rate_limit_exceeded
                    type: rate_limit
                    message: Trop de requêtes. Réessayez dans un instant.
                    code: 429
        '500':
          description: Création du compte de démonstration échouée (`server_error`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                server_error:
                  summary: Le compte de démonstration n'a pas pu être créé.
                  value:
                    error: server_error
                    type: api
                    message: Erreur serveur.
                    code: 500

  /terminal/connection-token:
    post:
      tags: [Terminal]
      summary: Jeton de connexion du lecteur de carte
      # Session marchand, jamais clé API : cf. le tag Terminal.
      security:
        - SessionCookie: []
      description: |
        Renvoie le jeton dont le SDK mobile a besoin pour s'apparier au
        lecteur de carte. Le jeton est à usage court : l'application en
        redemande un à chaque session d'encaissement.

        **Session marchand obligatoire.** Une clé `tpk_…` reçoit 401.
        Corps de requête vide.

        L'en-tête `Idempotency-Key` est IGNORÉ ici. Cette opération ne
        persiste rien : elle demande un jeton court au prestataire et le
        rend. Un second appel ne laisse aucune trace de plus, il n'y a donc
        rien à dédupliquer.
      responses:
        '200':
          description: Jeton émis
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Le prestataire n'a pas délivré de jeton (`token_creation_failed`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                token_creation_failed:
                  summary: Le prestataire n'a pas rendu de jeton de connexion.
                  value:
                    error: token_creation_failed
                    type: provider
                    message: Jeton de connexion indisponible.
                    code: 500

  /terminal/checkout:
    post:
      tags: [Terminal]
      summary: Ouvrir un encaissement au comptoir
      security:
        - SessionCookie: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      description: |
        Crée l'encaissement que l'application mobile présente ensuite au
        lecteur de carte, et la transaction Tupay correspondante.

        **Toujours en mode réel.** Un encaissement au comptoir n'a pas de
        variante bac à sable : `livemode` vaut systématiquement `true`.

        **Session marchand obligatoire.** Une clé `tpk_…` reçoit 401.
        `merchantId` doit être celui de la session, sinon 403.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amountXpf, merchantId]
              properties:
                amountXpf:
                  type: integer
                  minimum: 100
                  maximum: 10000000
                  examples: [4500]
                merchantId:
                  type: string
                  format: uuid
                  description: Doit être le marchand de la session en cours.
                  examples: ['2b9d7c14-6f3a-42e8-b05c-9a1e4d7f6c28']
                description:
                  type: string
                  maxLength: 256
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '201':
          description: Encaissement ouvert
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkoutId:
                    type: string
                    description: Identifiant à passer au SDK du lecteur.
                  transactionId:
                    type: string
                    format: uuid
                    description: Identifiant Tupay, utilisé par les remboursements, les webhooks et l'export.
                  amountXpf: { type: integer }
                  amountEurCents: { type: integer }
        '400':
          description: |
            `missing_idempotency_key` si l'en-tête `Idempotency-Key` manque,
            `bad_request` si le corps n'est pas du JSON valide.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missing_idempotency_key:
                  summary: En-tête `Idempotency-Key` absent.
                  value:
                    error: missing_idempotency_key
                    type: invalid_request
                    message: En-tête Idempotency-Key requis.
                    code: 400
                bad_request:
                  summary: Corps de requête illisible.
                  value:
                    error: bad_request
                    type: invalid_request
                    message: Corps JSON invalide.
                    code: 400
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Le prestataire n'a pas ouvert l'encaissement (`checkout_creation_failed`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                checkout_creation_failed:
                  summary: Le prestataire n'a pas ouvert l'encaissement au comptoir.
                  value:
                    error: checkout_creation_failed
                    type: provider
                    message: Encaissement indisponible.
                    code: 500

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer tpk_live_…` ou `Authorization: Bearer tpk_test_…`
    SessionCookie:
      type: apiKey
      in: cookie
      name: authjs.session-token
      description: |
        Session marchand Auth.js (JWT en cookie, 7 jours). Le nom du cookie
        est `__Secure-authjs.session-token` en HTTPS.

        Utilisée par l'application mobile de terminal. Une clé `tpk_…` ne
        fonctionne **PAS** sur ces routes : elle reçoit 401.

  parameters:
    EventId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^evt_', examples: ['evt_9f8c1a2b3d4e5f60'] }
      description: Identifiant d'événement, tel qu'il apparaît dans l'enveloppe reçue.
    TupayVersion:
      name: Tupay-Version
      in: header
      required: false
      schema: { type: string, example: '2026-01-01' }
      description: |
        Version d'API à appliquer, au format `AAAA-MM-JJ`.

        Facultatif. Sans cet en-tête, la version **figée à la création de
        votre clé** s'applique : une intégration existante ne change jamais
        de forme, même quand l'API évolue.

        Le préciser sert à coder explicitement contre une version donnée.
        Une valeur inconnue est refusée par un `400 unsupported_version` :
        jamais ignorée silencieusement.

        Les webhooks suivent la version figée à la création de l'**endpoint**,
        indépendante de celle de vos clés : chaque enveloppe la porte dans son
        champ `apiVersion`.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, examples: ['9f8c1a2b-3d4e-4f60-8a71-5b2c9d0e6f13'] }
      description: |
        Obligatoire sur cette opération. UUID v4 recommandé. Rejouer la même
        clé renvoie le résultat mémorisé sans réexécuter l'opération : un
        retry après timeout ne débite jamais deux fois.

        La clé est cloisonnée par marchand : deux comptes peuvent employer la
        même chaîne sans jamais se voir.

        Rejouer une clé avec un corps différent vaut 409
        `idempotency_key_reuse`. Seuls l'ordre des clés JSON et l'espacement
        sont ignorés dans la comparaison. La méthode et le chemin font partie
        de l'empreinte : la même clé sur une autre opération est donc un corps
        différent.

        Une seconde requête arrivant pendant que la première s'exécute attend
        au plus trois secondes, puis reçoit 409 `idempotency_in_progress`. Il
        faut alors retenter la même requête à l'identique.

        Rétention : 24 heures. Passé ce délai la clé redevient libre, et la
        rejouer CRÉE une nouvelle ressource au lieu de rendre le corps
        mémorisé.
    IdempotencyKeyFacultative:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, examples: ['9f8c1a2b-3d4e-4f60-8a71-5b2c9d0e6f13'] }
      description: |
        Facultative sur cette opération. Fournie, elle garantit qu'un rejeu ne
        crée pas une seconde ressource : le corps mémorisé est renvoyé tel
        quel. Absente, chaque appel crée une ressource de plus, comme avant.

        Mêmes règles que sur les opérations qui l'exigent : cloisonnement par
        marchand, refus 409 `idempotency_key_reuse` sur corps différent, refus
        409 `idempotency_in_progress` pendant l'exécution de la première, et
        rétention de 24 heures.

        Exception, dite franchement : sur `POST /api-keys` et
        `POST /webhook-endpoints` le secret n'est rendu qu'une fois. Il n'est
        PAS conservé avec le corps mémorisé, sinon il séjournerait 24 heures
        en base en clair. Un rejeu rend donc la ressource avec son secret à
        `null`. La clé garantit qu'une seule ressource existe, pas que le
        secret soit redonné.
    Expand:
      name: expand[]
      in: query
      required: false
      explode: true
      schema:
        type: array
        items: { type: string }
      description: |
        Développe une référence : le champ porte l'objet complet au lieu de
        son seul identifiant, qui reste servi. Répétez le paramètre pour
        plusieurs chemins.

        Chemins acceptés : `payment_method` → `customer` ;
        `payment_link` → `price` ; `price` → `product` ; `setup_intent` → `customer`, `payment_method` ;
        `transaction` → `payment_method` ;
        `transaction` → `payment_link`, `customer` ;
        `refund` → `transaction`, `transaction.payment_link`,
        `transaction.customer` ; `webhook_delivery` → `event`. Un chemin
        inconnu vaut 400, en énumérant les chemins acceptés.

        Profondeur maximale : 2. Au delà, 400 : un expand sans borne est une
        attaque par amplification.
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid, examples: ['8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4'] }
    Limit:
      name: limit
      in: query
      description: |
        Nombre d'éléments par page, entre 1 et 100. Vaut 25 par défaut sur
        toutes les collections.

        Une valeur hors bornes est refusée par un `400 validation_error`,
        jamais rabattue en silence sur 100.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    StartingAfter:
      name: starting_after
      in: query
      description: |
        Curseur. Ne rend que les éléments situés APRÈS celui-ci dans
        l'ordre de parcours, donc plus anciens. L'élément désigné n'est
        jamais inclus dans la page.

        C'est le paramètre du parcours normal : lisez une page, reprenez
        avec l'identifiant de son dernier élément.

        Ne se combine ni avec `ending_before`, ni avec `offset`.
      schema: { type: string, examples: ['8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4'] }
    EndingBefore:
      name: ending_before
      in: query
      description: |
        Curseur inverse. Ne rend que les éléments situés AVANT celui-ci
        dans l'ordre de parcours, donc plus récents. L'élément désigné
        n'est jamais inclus dans la page.

        Sous ce paramètre, `has_more` parle de ce qui reste avant la
        page, pas après.

        Ne se combine ni avec `starting_after`, ni avec `offset`.
      schema: { type: string, examples: ['8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4'] }
    Offset:
      name: offset
      in: query
      deprecated: true
      description: |
        Déprécié, remplacé par `starting_after`. Reste accepté au minimum
        douze mois, avec exactement le comportement qu'il a toujours eu.

        Pourquoi il est déprécié : un décalage nomme une POSITION dans un
        classement qui bouge. Dès qu'une ligne naît pendant votre
        parcours, tout ce qui suit glisse d'un cran, et vous sautez un
        élément sans rien voir. Un curseur nomme une LIGNE, et ne glisse
        pas.

        Il n'est pas traduit en curseur : un décalage n'a pas
        d'équivalent en curseur, et prétendre le contraire vous rendrait
        une position instable rebaptisée.

        Ne se combine ni avec `starting_after`, ni avec `ending_before`.
      schema: { type: integer, minimum: 0, default: 0 }

  schemas:
    Metadata:
      type: object
      description: |
        ⚠️ Une valeur qui ressemble à un numéro de carte bancaire est
        REFUSÉE, sur toutes les ressources : Tupay n'en stocke jamais, y
        compris ici. Ce refus protège votre conformité PCI-DSS autant que
        la nôtre. Si c'est une référence interne numérique, préfixez-la
        (« cmd-… ») pour lever l'ambiguïté.

        Vos propres clés et valeurs, attachées à l'objet et relisibles
        avec lui. De quoi relier une ressource Tupay à votre système sans
        tenir de table de correspondance.

        Bornes : 50 clés au plus, 40 caractères par clé, 500 par valeur.
        Elles portent sur le RÉSULTAT de la fusion, pas sur ce que vous
        envoyez : ajouter 5 clés à un objet qui en compte 48 est refusé.

        À la modification, les clés fournies remplacent les anciennes et
        les autres survivent. Une clé à `null` est supprimée. Un objet
        vide ne change rien, et `metadata: null` efface tout.

        Ce n'est pas un espace de stockage : les valeurs ne sont ni
        indexées ni cherchables, et n'ont aucun effet sur le traitement.
      maxProperties: 50
      additionalProperties:
        type: string
        maxLength: 500
      propertyNames:
        type: string
        maxLength: 40

    Transaction:
      type: object
      description: |
        Forme identique dans les réponses REST et dans `data.object` des
        webhooks : un seul modèle à écrire côté intégrateur.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: transaction }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        amountXpf: { type: integer }
        amountEurCents:
          type: integer
          description: Centimes EUR, pour réconciliation avec le prestataire bancaire.
        currency: { type: string, const: XPF }
        status:
          type: string
          enum: [pending, processing, succeeded, failed, refunded]
        paymentIntentId: { type: string }
        customerId:
          type: [string, 'null']
          format: uuid
          description: |
            Client rattaché, `null` pour un encaissement de passage.
            Développable : `expand[]=customer`.
        paymentMethodId:
          type: [string, 'null']
          format: uuid
          description: |
            Carte enregistrée débitée, `null` si le porteur a saisi la
            sienne. Développable : `expand[]=payment_method`.
        offSession:
          type: boolean
          description: Le porteur n'était pas devant son écran.
        failureCode:
          type: [string, 'null']
          enum: [card_declined, expired_card, incorrect_cvc, insufficient_funds, processing_error, authentication_required, null]
          description: |
            Sur un échec : la raison, en code machine stable.
            `authentication_required` n'est **pas** un refus.
        failureMessage:
          type: [string, 'null']
          description: La même raison, en français, montrable au marchand.
        paymentLinkId: { type: [string, 'null'], format: uuid }
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        refundedAmountXpf:
          type: integer
          description: |
            Cumul remboursé. C'est ce champ qui fait foi, pas `status` :
            un remboursement partiel laisse `status` à `succeeded`.
        refundedAt: { type: [string, 'null'], format: date-time }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    PaymentLink:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: payment_link }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        amountXpf: { type: integer }
        currency: { type: string, const: XPF }
        description: { type: string }
        slug: { type: string }
        url:
          type: string
          description: URL publique à transmettre au client.
        returnUrl: { type: [string, 'null'], format: uri }
        priceId:
          type: [string, 'null']
          format: uuid
          description: |
            Tarif dont ce lien hérite, `null` pour un montant libre.
            Développable — `expand[]=price`. Le montant reste servi dans
            `amountXpf` : un client qui le lit déjà n'a rien à changer.
        metadata: { $ref: '#/components/schemas/Metadata' }
        active: { type: boolean }
        livemode:
          type: boolean
          description: Un lien de test n'est pas payable. La page affiche un bandeau.
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    Customer:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: customer }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        email: { type: [string, 'null'], format: email }
        name: { type: [string, 'null'] }
        phone: { type: [string, 'null'] }
        metadata: { $ref: '#/components/schemas/Metadata' }
        deleted:
          type: boolean
          description: |
            Toujours présent, y compris à `false`. Un client supprimé
            reste lisible et garde ses données : seule la fiche est
            retirée de la liste et fermée à la modification.
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    PaymentMethod:
      type: object
      description: |
        ⚠️ **Cette forme est le périmètre PCI-DSS.** Elle ne porte que des
        attributs d'affichage : ni numéro de carte, ni cryptogramme, ni
        titulaire — ni même le jeton du prestataire, qui reste chez nous.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: payment_method }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        customerId:
          type: string
          format: uuid
          description: Client rattaché. Développable — `expand[]=customer`.
        brand:
          type: string
          examples: ['visa']
          description: Marque, telle que le prestataire la nomme.
        last4:
          type: string
          pattern: '^[0-9]{4}$'
          examples: ['4242']
          description: Les quatre derniers chiffres. Jamais plus.
        expMonth: { type: integer, minimum: 1, maximum: 12 }
        expYear: { type: integer, examples: [2034] }
        default:
          type: boolean
          description: |
            Utilisé quand un paiement ne précise aucun moyen. Toujours
            présent, y compris à `false`.
        detached:
          type: boolean
          description: |
            Vrai après `DELETE`. Le moyen n'est plus utilisable, mais
            reste lisible et garde ses attributs d'affichage.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    SetupIntent:
      type: object
      description: |
        Une intention d'ENREGISTREMENT de carte. Elle ne porte aucun
        montant, et n'en acceptera jamais : aucun débit ne peut venir de
        cet objet.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: setup_intent }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        customerId:
          type: string
          format: uuid
          description: Développable — `expand[]=customer`.
        status:
          type: string
          enum: [pending, succeeded, failed, canceled]
        paymentMethodId:
          type: [string, 'null']
          format: uuid
          description: |
            Le moyen créé quand l'enregistrement aboutit, `null` avant.
            Développable — `expand[]=payment_method`.
        failureCode:
          type: [string, 'null']
          enum: [card_declined, expired_card, incorrect_cvc, insufficient_funds, processing_error, authentication_required, null]
          description: |
            Code machine STABLE. `authentication_required` n'est **pas**
            un refus : le porteur doit valider son authentification
            forte, ramenez-le devant son écran.
        failureMessage:
          type: [string, 'null']
          description: Phrase française, montrable telle quelle au client final.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: product }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        name: { type: string, examples: ['Sortie lagon'] }
        description: { type: [string, 'null'] }
        active:
          type: boolean
          description: |
            Un article désactivé n'apparaît plus dans les nouvelles
            ventes, mais ses tarifs existants continuent de fonctionner.
            **Réversible** — ce n'est pas une suppression.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    CheckoutSession:
      type: object
      description: |
        Une page de paiement hébergée. Le total est figé à la création
        et ne suit plus le catalogue.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: checkout_session }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        status:
          type: string
          enum: [open, complete, expired]
          description: |
            `open` tant qu'elle est payable, `complete` une fois
            **l'argent reçu**, `expired` passé l'échéance.

            Une session ne quitte jamais `complete`. Elle peut en
            revanche passer d'`expired` à `complete` : un paiement lancé
            avant l'échéance et confirmé après elle a bel et bien eu
            lieu, et c'est l'argent qui tranche.
        url:
          type: string
          description: La page hébergée par Tupay. C'est là qu'on envoie le client.
        amountXpf:
          type: integer
          description: |
            Total figé, somme des lignes. Entier XPF — le franc
            pacifique n'a pas de centimes.
        currency: { type: string, const: XPF }
        lineItems:
          type: array
          description: Ce que la page affiche, tel qu'il a été figé.
          items:
            type: object
            properties:
              priceId:
                type: [string, 'null']
                format: uuid
                description: '`null` pour une ligne à montant libre.'
              description: { type: string }
              unitAmountXpf: { type: integer }
              quantity: { type: integer }
              amountXpf:
                type: integer
                description: '`unitAmountXpf` × `quantity`.'
        successUrl: { type: string }
        cancelUrl: { type: [string, 'null'] }
        customerId:
          type: [string, 'null']
          format: uuid
          description: Développable — `expand[]=customer`.
        customerEmail: { type: [string, 'null'], format: email }
        customerMatch:
          type: [string, 'null']
          enum: [provided, created, unique, ambiguous, null]
          description: |
            Comment la fiche a été obtenue au règlement. `null` tant que
            la session n'est pas réglée, ou s'il n'y avait rien à
            rapprocher.

            | Valeur | Ce qui s'est passé |
            |---|---|
            | `provided` | vous aviez fourni `customer` |
            | `created` | l'email était inconnu, une fiche a été créée |
            | `unique` | **une seule** fiche vivante portait cet email |
            | `ambiguous` | **plusieurs** la portaient — une fiche NEUVE a été créée |

            `ambiguous` est le seul cas qui demande une action de votre
            part. Nous ne rattachons jamais « à la plus récente » : cela
            ferait entrer l'achat d'une personne dans l'historique d'une
            autre.
        transactionId:
          type: [string, 'null']
          format: uuid
          description: |
            Le paiement en cours tant que la session est `open` — il en
            interdit un second — puis celui qui l'a réglée. Développable
            — `expand[]=transaction`.
        expiresAt:
          type: integer
          description: Secondes Unix. Entre 30 minutes et 24 heures après `created`.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    Receipt:
      type: object
      description: |
        La trace d'un reçu envoyé au client final. Le sujet et le corps
        de l'email ne sont pas rendus : le reçu s'adresse à votre client,
        cette ressource vous dit seulement ce qui lui est arrivé.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: receipt }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        status:
          type: string
          enum: [pending, succeeded, failed, exhausted]
          description: |
            `exhausted` = les sept tentatives sont épuisées, ou le
            fournisseur a refusé définitivement (adresse inexistante).
            Rien ne repartira sans `POST /receipts/{id}/resend`.
        email:
          type: string
          format: email
          description: |
            L'adresse **figée au moment du paiement**. Corriger ou
            supprimer la fiche client ensuite ne la change pas : le reçu
            est parti là où votre client l'attendait.
        transactionId:
          type: string
          format: uuid
          description: Développable — `expand[]=transaction`.
        attempts: { type: integer }
        nextRetryAt:
          type: [integer, 'null']
          description: Secondes Unix. `null` quand plus rien n'est prévu.
        sentAt: { type: [integer, 'null'], description: Secondes Unix. }
        provider:
          type: [string, 'null']
          description: |
            Qui a traité l'envoi. `resend` = parti. **`journal` =
            consigné, pas expédié** — un environnement sans email
            configuré, ce qui n'arrive pas en production.
        lastError: { type: [string, 'null'] }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.

    Subscription:
      type: object
      description: |
        Un client lié à un tarif récurrent. Le tarif est **toujours**
        récurrent — un abonnement sur un tarif ponctuel est refusé, y
        compris par la base.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: subscription }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        status:
          type: string
          enum: [active, paused, past_due, canceled]
          description: |
            `past_due` : le client n'a pas payé et il n'est **pas
            coupé**. Nous ne résilions pas à votre place — la décision
            vous appartient.
        customerId:
          type: string
          format: uuid
          description: Développable — `expand[]=customer`.
        priceId:
          type: string
          format: uuid
          description: Développable — `expand[]=price`.
        paymentMethodId:
          type: string
          format: uuid
          description: |
            La carte retenue à la souscription. **Non développable** :
            elle porte les quatre derniers chiffres d'une carte, et une
            liste d'abonnements n'a pas à les faire défiler. Lisez-la par
            `GET /payment-methods/{id}`.
        cancelAtPeriodEnd:
          type: boolean
          description: |
            Annulation **programmée** : le service court jusqu'à
            `currentPeriodEnd`, puis l'abonnement se ferme.

            Distincte de `canceledAt`, qui date la cessation effective.
            Sur un abonnement `canceled`, ce champ dit **comment** il
            s'est terminé : `true` = comme prévu, `false` = coupé net.
        canceledAt:
          type: [integer, 'null']
          description: Secondes Unix. `null` tant que l'abonnement n'a pas cessé.
        currentPeriodStart:
          type: integer
          description: Secondes Unix.
        currentPeriodEnd:
          type: integer
          description: |
            Secondes Unix. Fin de la période courante, **donc la
            prochaine échéance**. Il n'y a pas de second champ pour cet
            instant : deux champs pour une même date finiraient par
            diverger.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    BalanceTransaction:
      type: object
      description: |
        Une ligne du journal de votre solde. **Immuable** : elle naît
        d'un mouvement réel et ne se corrige qu'en écrivant un
        ajustement.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: balance_transaction }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        sequence:
          type: integer
          description: |
            Numéro séquentiel par compte, par mode et par année,
            **sans rupture**. Une ligne manquante laisse un trou visible.
        type:
          type: string
          enum: [payment, refund, fee, payout, dispute, adjustment]
        transactionId:
          type: [string, 'null']
          format: uuid
          description: |
            Le paiement à l'origine du mouvement. Développable —
            `expand[]=transaction`.
        refundId:
          type: [string, 'null']
          format: uuid
          description: Développable — `expand[]=refund`.
        grossXpf:
          type: integer
          description: |
            Ce que le client a payé, **TTC**. **Négatif** sur un
            remboursement : le journal s'additionne sans cas particulier.
        feeXpf:
          type: integer
          description: |
            Notre commission. **Zéro** sur un remboursement — elle n'est
            pas rendue, et celle du paiement d'origine reste acquise.
        netXpf:
          type: integer
          description: '`grossXpf − feeXpf`. C''est lui qui rejoint votre solde.'
        tgcXpf:
          type: integer
          description: |
            La TGC transportée par ce mouvement, de même signe que le
            brut. **Indicative** : calculée au taux déclaré sur votre
            compte.
        tgcRate:
          type: string
          enum: [exempt, '5', '10', '16']
          description: Le taux **figé** au moment de l'écriture.
        tgcLabel:
          type: string
          description: '« TGC 10 % incluse : 4 091 XPF », ou l''exonération.'
        feeBps:
          type: integer
          description: Le barème appliqué, **figé**. 250 = 2,5 %.
        feeFixedXpf:
          type: integer
          description: Part fixe du barème, **figée**.
        availableAt:
          type: integer
          description: |
            Secondes Unix : quand ce montant bascule en `available`.
            Figée à l'écriture, jamais recalculée.
        currency: { type: string, const: XPF }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.

    Balance:
      type: object
      description: |
        État instantané, pas une ressource stockée : ni `id`, ni
        `created`. Le solde est la **somme** de vos écritures au moment
        où vous le demandez.
      properties:
        object: { type: string, const: balance }
        available:
          allOf:
            - $ref: '#/components/schemas/PartDeSolde'
          description: Encaissable dès maintenant.
        pending:
          allOf:
            - $ref: '#/components/schemas/PartDeSolde'
          description: |
            Pas encore encaissable. `nextAvailableAt` dit quand la plus
            proche de ces écritures bascule.
        nextAvailableAt:
          type: [integer, 'null']
          description: |
            Secondes Unix : quand la plus proche écriture en attente
            bascule. `null` quand rien n'est en attente.
        currency: { type: string, const: XPF }
        livemode: { type: boolean }

    PartDeSolde:
      type: object
      properties:
        amountXpf:
          type: integer
          description: |
            Ce qui vous revient, **commission déduite**, en francs
            entiers.
        tgcXpf:
          type: integer
          description: |
            La TGC contenue dans `amountXpf`. **Indicatif** : calculé au
            taux déclaré sur votre compte.
        netOfTgcXpf:
          type: integer
          description: '`amountXpf − tgcXpf` — ce qui reste une fois la taxe réservée.'
        count:
          type: integer
          description: Nombre d'écritures dans cette part.

    Invoice:
      type: object
      description: |
        Facture d'une échéance d'abonnement. **Montants TTC**, ventilation
        TGC figée à l'émission.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: invoice }
        number:
          type: string
          example: FAC-2026-000004
          description: |
            Le numéro **comptable** : séquentiel, chronologique et sans
            rupture, par compte et par année. C'est lui qu'on cite à un
            comptable ; `id` ne désigne la facture que dans l'API.

            Attribué par Tupay, jamais modifiable. Le mode test a son
            propre compteur et son propre préfixe — `TEST-2026-000004` —
            pour qu'une intégration en cours ne creuse pas de trou dans
            votre séquence réelle.
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        status:
          type: string
          enum: [open, paid, past_due, void]
        subscriptionId:
          type: string
          format: uuid
          description: Développable — `expand[]=subscription`.
        customerId:
          type: string
          format: uuid
          description: Développable — `expand[]=customer`.
        transactionId:
          type: [string, 'null']
          format: uuid
          description: |
            Le prélèvement tenté. `null` tant qu'aucune tentative n'a eu
            lieu. Développable — `expand[]=transaction`.
        amountXpf:
          type: integer
          description: |
            **TTC**, entier XPF. `htXpf + tgcXpf` vaut exactement cette
            valeur — la base le vérifie, quel que soit le code qui écrit.
        htXpf: { type: integer, description: Base hors taxe. }
        tgcXpf:
          type: integer
          description: |
            Taxe **comprise** dans le TTC, jamais ajoutée. Sur 6 000 XPF
            à 5 % elle vaut 286, et non 300.
        tgcRate:
          type: string
          enum: [exempt, '5', '10', '16']
          description: |
            Le taux **figé à l'émission**, pas celui de votre compte
            aujourd'hui. C'est ce qui rend une facture relisible des
            années plus tard.
        tgcLabel:
          type: string
          description: '« TGC 5 % incluse : 286 XPF », ou l''exonération.'
        currency: { type: string, const: XPF }
        periodStart: { type: integer, description: Secondes Unix. }
        periodEnd: { type: integer, description: Secondes Unix. }
        lines:
          type: array
          description: Rendues d'office — une facture sans ses lignes n'en est pas une.
          items:
            type: object
            properties:
              description: { type: string }
              quantity: { type: integer }
              unitAmountXpf: { type: integer }
              amountXpf: { type: integer, description: 'TTC.' }
              htXpf: { type: integer }
              tgcXpf: { type: integer }
        attempts:
          type: integer
          description: |
            Tentatives de prélèvement déjà faites, celle de l'émission
            comprise.
        nextAttemptAt:
          type: [integer, 'null']
          description: |
            Prochaine relance prévue, en secondes Unix. `null` = plus
            rien : la facture est réglée, ou le barème est épuisé.
        attemptHistory:
          type: array
          description: |
            Le journal des tentatives. Rendu **seulement** sur
            `GET /invoices/{id}` : cinq lignes par facture, personne ne
            lit cent journaux d'un coup.

            Un compteur dit combien ; ce journal dit quand et pourquoi.
          items:
            type: object
            properties:
              attempt: { type: integer }
              succeeded: { type: boolean }
              transactionId: { type: [string, 'null'], format: uuid }
              failureCode:
                type: [string, 'null']
                description: Code machine, du catalogue d'erreurs.
              failureMessage:
                type: [string, 'null']
                description: Message français, à montrer au marchand.
              attemptedAt: { type: integer, description: Secondes Unix. }
        paidAt:
          type: [integer, 'null']
          description: Secondes Unix. `null` tant que la facture n'est pas réglée.
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    Price:
      type: object
      description: |
        Un prix attaché à un article. **Immuable dès sa création** :
        montant, devise, périodicité et article ne changent pas.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: price }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        productId:
          type: string
          format: uuid
          description: Développable — `expand[]=product`.
        amountXpf:
          type: integer
          description: Entier XPF. Le franc pacifique n'a pas de centimes.
        currency: { type: string, const: XPF }
        recurring:
          type: [object, 'null']
          description: '`null` pour un tarif ponctuel.'
          properties:
            interval: { type: string, enum: [day, week, month, year] }
            intervalCount: { type: integer }
        nickname: { type: [string, 'null'] }
        active:
          type: boolean
          description: |
            Un tarif désactivé n'est plus proposé, mais reste consultable
            et continue d'honorer les liens qui le référencent.
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }

    Refund:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: refund }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        transactionId: { type: string, format: uuid }
        amountXpf: { type: integer }
        currency: { type: string, const: XPF }
        status:
          type: string
          enum: [pending, succeeded, failed]
        reason: { type: [string, 'null'] }
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: webhook_endpoint }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        url: { type: string, format: uri }
        description: { type: [string, 'null'] }
        enabledEvents:
          type: array
          items: { type: string }
        status:
          type: string
          enum: [enabled, disabled]
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }
        disabledAt: { type: [string, 'null'], format: date-time }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: webhook_delivery }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        eventId: { type: string }
        eventType: { type: string }
        status:
          type: string
          enum: [pending, succeeded, failed, exhausted]
        attempts: { type: integer }
        responseStatus: { type: [integer, 'null'] }
        responseBody: { type: [string, 'null'] }
        lastError: { type: [string, 'null'] }
        nextRetryAt: { type: [string, 'null'], format: date-time }
        deliveredAt: { type: [string, 'null'], format: date-time }
        livemode:
          type: boolean
          description: |
            Le mode de l'endpoint destinataire — une livraison n'en porte
            pas elle-même. Sans lui, vous ne pouviez pas savoir si une
            livraison affichée était réelle ou de test sans remonter à
            son endpoint.
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.

    Merchant:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: merchant }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
        email: { type: string, format: email }
        name: { type: string }
        kybStatus:
          type: string
          enum: [pending, validated, rejected]
        ibanXpf: { type: [string, 'null'] }
        tgcRate:
          type: string
          enum: [exempt, '5', '10', '16']
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        updatedAt: { type: string, format: date-time }
        kyb_status:
          type: string
          enum: [pending, validated, rejected]
          deprecated: true
          description: |
            Doublon de `kybStatus`. Servi au minimum douze mois, puis retiré
            par une version datée.
        iban_xpf:
          type: [string, 'null']
          deprecated: true
          description: Doublon de `ibanXpf`. Lisez `ibanXpf`.
        tgc_rate:
          type: string
          enum: [exempt, '5', '10', '16']
          deprecated: true
          description: Doublon de `tgcRate`. Lisez `tgcRate`.
        created_at:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `createdAt`. Lisez `createdAt`, ou `created`.
        updated_at:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `updatedAt`. Lisez `updatedAt`.

    Event:
      type: object
      description: |
        Événement tel que l'API le RESTITUE.

        À ne pas confondre avec `WebhookEvent`, l'enveloppe REÇUE sur
        votre endpoint. Les deux décrivent le même fait, mais l'enveloppe
        porte en plus `apiVersion` : elle est construite au moment de la
        livraison, pour la version de l'endpoint destinataire.
      properties:
        id: { type: string, example: evt_9f8c1a2b3d4e5f60 }
        object: { type: string, enum: [event] }
        created:
          type: integer
          description: Secondes Unix. Même instant que `createdAt`.
          example: 1767225600
        type: { type: string, example: payment.succeeded }
        createdAt:
          type: string
          format: date-time
          deprecated: true
          description: Doublon de `created`. Lisez `created`.
        livemode: { type: boolean }
        data:
          type: object
          properties:
            object: { $ref: '#/components/schemas/Transaction' }

    WebhookEvent:
      type: object
      description: |
        Enveloppe POSTée sur les endpoints déclarés.

        Signature dans le header `Tupay-Signature: t=<unix>,v1=<hmac hex>` :
        HMAC-SHA256 de `${t}.${corps brut}` avec le secret de l'endpoint.
        Vérification obligatoire côté marchand, tolérance 5 min.
      properties:
        id:
          type: string
          description: Identifiant d'événement. Dédupliquez dessus.
        object: { type: string, const: event }
        type:
          type: string
          enum: [payment.succeeded, payment.failed, payment.processing, payment.refunded]
        apiVersion: { type: string, const: '2026-01-01' }
        created: { type: integer, description: Horodatage Unix en secondes. }
        livemode: { type: boolean }
        data:
          type: object
          properties:
            object: { $ref: '#/components/schemas/Transaction' }

    Error:
      type: object
      description: |
        Testez `error` (code machine stable). `message` est destiné à un
        humain, est en français et **peut changer sans préavis** : une
        reformulation ne change jamais le code.

        Retirer ou renommer un code est un changement cassant, réservé à une
        version datée. En ajouter un ne l'est pas : traitez un code inconnu
        comme sa classe `type`.

        `type` est la CLASSE de l'erreur. Elle permet un seul branchement
        pour toute une famille, sans énumérer chaque code.
      required: [error, type, message, code]
      properties:
        error:
          type: string
          description: Code machine stable, en snake_case anglais.
        type:
          type: string
          description: |
            Classe de l'erreur, dans un ensemble fermé.

            `invalid_request` : l'appel est mal formé ou hors bornes,
            corrigez la requête. `authentication` : aucune identité valide.
            `permission` : identité valide, opération refusée.
            `not_found` : la ressource n'existe pas, ou pas pour vous.
            `conflict` : l'état courant interdit l'opération, ne retentez
            pas à l'identique. `idempotency` : le refus porte sur la clé
            d'idempotence, pas sur la requête. `rate_limit` : trop d'appels.
            `provider` : le prestataire bancaire a refusé ou n'a pas
            répondu. `api` : incident de notre côté, rien à corriger chez
            vous.
          enum:
            - invalid_request
            - authentication
            - permission
            - not_found
            - conflict
            - idempotency
            - rate_limit
            - provider
            - api
        message:
          type: string
          description: Destiné à un humain, toujours en français.
        code:
          type: integer
          description: Statut HTTP, repris dans le corps par commodité.

  # Chaque réponse partagée nomme les CODES MACHINE qu'elle peut porter et
  # montre le corps exact. Sans ça, la référence publique annonce un statut
  # HTTP sans jamais dire sur quoi brancher un `if` — or `message` est en
  # français et peut changer, seul `error` est stable.
  #
  # Les codes viennent de `ApiErrorCode` (src/lib/api-response.ts) : c'est
  # une transcription, jamais une invention. Le `code` de chaque exemple est
  # le statut HTTP réellement renvoyé avec CE code machine — une réponse
  # partagée entre 400 et 422 porte donc des exemples de codes différents.
  responses:
    BadRequest:
      description: Requête invalide
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            validation_error:
              summary: Un champ du corps ne respecte pas son contrat (422).
              value:
                error: validation_error
                type: invalid_request
                message: Le montant minimum est de 100 XPF.
                code: 422
            bad_request:
              summary: Corps absent ou illisible, paramètre de requête hors bornes (400).
              value:
                error: bad_request
                type: invalid_request
                message: Corps JSON invalide.
                code: 400
            invalid_cursor:
              summary: Curseur de pagination qui ne se résout pas (400).
              value:
                error: invalid_cursor
                type: invalid_request
                message: >-
                  Le curseur « starting_after » ne désigne aucun élément de
                  cette collection : « 8f14e45f-ceea-4c1a-9b7d-2a3f6c8e01b4 ».
                  Reprenez le parcours depuis le début.
                code: 400
            missing_idempotency_key:
              summary: En-tête `Idempotency-Key` absent sur une opération qui l'exige (400).
              value:
                error: missing_idempotency_key
                type: invalid_request
                message: Header Idempotency-Key obligatoire.
                code: 400
    UnsupportedVersion:
      description: Version d'API inconnue
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unsupported_version:
              summary: >-
                L'en-tête `Tupay-Version` désigne une version non publiée.
                Reprise : employez une version de la liste, ou retirez
                l'en-tête pour laisser s'appliquer celle de votre clé.
              value:
                error: unsupported_version
                type: invalid_request
                message: >-
                  Version d’API inconnue. Versions publiées : 2026-01-01.
                code: 400
    Unauthorized:
      description: Clé absente, invalide ou révoquée
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unauthorized:
              summary: Aucune identité valide n'a pu être établie.
              value:
                error: unauthorized
                type: authentication
                message: Authentification requise.
                code: 401
    Forbidden:
      description: Opération interdite dans ce contexte
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            forbidden:
              summary: Identité valide, mais l'opération lui est refusée.
              value:
                error: forbidden
                type: permission
                message: Accès refusé.
                code: 403
    NotFound:
      description: |
        Ressource inexistante, hors de votre périmètre, ou appartenant à
        l'AUTRE MODE. Nous renvoyons 404 plutôt que 403 pour ne pas confirmer
        l'existence de ce qui ne vous
        appartient pas.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            not_found:
              summary: Inexistante, ou appartenant à un autre marchand.
              value:
                error: not_found
                type: not_found
                message: Ressource introuvable.
                code: 404
    Conflict:
      description: État incompatible avec l'opération
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            conflict:
              summary: L'état courant de la ressource interdit l'opération.
              value:
                error: conflict
                type: conflict
                message: Cette transaction est déjà intégralement remboursée.
                code: 409
            idempotency_key_reuse:
              summary: >-
                Clé déjà employée pour une AUTRE requête (409). Reprise :
                choisissez une nouvelle clé. Aucun des deux corps n'a été
                appliqué.
              value:
                error: idempotency_key_reuse
                type: idempotency
                message: >-
                  Cette clé d’idempotence a déjà servi pour une requête
                  différente. Choisissez-en une nouvelle : rejouer une clé ne
                  vaut que pour la requête exacte qui l’a créée.
                code: 409
            idempotency_in_progress:
              summary: >-
                Une requête portant la même clé est encore en cours (409).
                Reprise : retentez la même requête à l'identique dans un
                instant. Aucune seconde ressource n'a été créée.
              value:
                error: idempotency_in_progress
                type: idempotency
                message: >-
                  Une requête portant cette clé est encore en cours. Retentez
                  le même appel, avec la même clé, dans un instant.
                code: 409
    RateLimited:
      description: 100 req/min par marchand dépassées
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rate_limit_exceeded:
              summary: Plafond de requêtes atteint. Réessayez après une pause.
              value:
                error: rate_limit_exceeded
                type: rate_limit
                message: Trop de requêtes. Réessayez dans un instant.
                code: 429
    BaasError:
      description: Le prestataire bancaire a refusé l'opération
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            bad_gateway:
              summary: |
                Refus venu du prestataire, pas de Tupay. Reprise : réessayez.

                `error` valait autrefois le code du prestataire lui-même
                (`card_declined`, `insufficient_funds`…). Il vaut désormais
                toujours `bad_gateway`, et le code d'origine part dans
                `providerCode`. Ce champ est OBSERVABLE mais pas stable : il
                ne fait pas partie de notre contrat, ne branchez pas dessus.
              value:
                error: bad_gateway
                type: provider
                message: >-
                  Le prestataire bancaire n’a pas pu traiter la demande.
                  Réessayez dans un instant ; si le refus persiste, le code du
                  prestataire est indiqué dans « providerCode ».
                code: 502
                providerCode: card_declined
