openapi: 3.1.0
info:
  title: API NexusPay
  version: "1.0"
  description: |
    API REST de NexusPay — sessions de paiement hébergées, catalogue produits,
    clients, facturation Factur-X et webhooks signés.

    **Authentification** : `Authorization: Bearer sk_…` (clé secrète, côté
    serveur uniquement). Les clés se créent depuis le dashboard
    (Développeurs & Webhooks) ou via `POST /api/keys` (JWT). Une clé peut être
    restreinte par scopes ; une clé créée avec `{"mode":"test"}` (préfixe
    `sk_test_`) opère dans un bac à sable étanche (`livemode: false`).

    **Conventions** : montants en centimes ; sessions de checkout en
    snake_case (héritage), le reste en camelCase ; statuts de facture
    `draft | open | paid | cancelled | refunded`.
  contact:
    url: https://nexuspay.fr/docs
servers:
  - url: https://api.nexuspay.fr
security:
  - secretKey: []
tags:
  - name: Checkout
    description: Sessions de paiement hébergées (snake_case). Hors bac à sable pour l'instant.
  - name: Produits
    description: Catalogue synchronisable depuis un CMS/ERP (camelCase).
  - name: Clients
    description: Base clients synchronisable depuis un CRM/ERP (camelCase).
  - name: Factures
    description: Facturation Factur-X — numérotation légale, PDF, envoi email, lien de paiement.
  - name: Devis
    description: Devis signables en ligne — numérotation DEV-*, conversion automatique en facture.
  - name: Paiements
    description: Lecture des encaissements (CB, crypto, virements) — réconciliation ERP.
  - name: Webhooks
    description: Endpoints sortants signés HMAC-SHA256 + journal des livraisons.

paths:
  /api/v1/checkout/sessions:
    post:
      tags: [Checkout]
      operationId: createCheckoutSession
      summary: Créer une session de paiement
      description: |
        Retourne l'URL de la page de paiement hébergée (valable 24 h).
        `Idempotency-Key` rend l'appel rejouable sans doublon (24 h).
        Une clé `pk_` ne peut créer que des sessions référençant le catalogue
        (`metadata.product_id`, `sku` ou `line_items`) — montant libre refusé (403).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutSessionCreate"
      responses:
        "201":
          description: Session créée
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "200":
          description: Rejeu idempotent — session déjà créée renvoyée telle quelle
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Produit catalogue introuvable, masqué ou en rupture de stock
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/checkout/sessions/{id}:
    get:
      tags: [Checkout]
      operationId: retrieveCheckoutSession
      summary: Récupérer une session
      description: "`{id}` est l'identifiant numérique (pour `ps_00000042`, appelez `/42`)."
      parameters:
        - $ref: "#/components/parameters/PathId"
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/checkout/sessions/{id}/refund:
    post:
      tags: [Checkout]
      operationId: refundCheckoutSession
      summary: Rembourser une session payée (total ou partiel)
      parameters:
        - $ref: "#/components/parameters/PathId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: integer
                  description: Centimes à rembourser. Défaut — montant total.
                reason:
                  type: string
                  enum: [requested_by_customer, duplicate, fraudulent]
      responses:
        "200":
          description: Remboursement effectué
          content:
            application/json:
              schema:
                type: object
                properties:
                  refund_id: { type: string }
                  refunded_amount: { type: integer }
                  currency: { type: string }
                  session_status: { type: string }
                  fully_refunded: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Session non remboursable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/checkout/sessions/{id}/simulate_payment:
    post:
      tags: [Checkout]
      operationId: simulateCheckoutPayment
      summary: Régler une session de TEST (sandbox) — idempotent
      description: |
        Réservé aux clés `sk_test_` et aux sessions `livemode: false` : passe
        la session en `succeeded` et émet `payment.session.succeeded`
        (`livemode: false`, `simulated: true`) — votre handler de webhook se
        teste de bout en bout, signature comprise. Aucun Stripe, aucune
        facture, aucune écriture comptable. Une session de test ne peut JAMAIS
        encaisser d'argent réel (le paiement carte y est bloqué).
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Session réglée (ou déjà réglée — idempotent)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Clé live — simulate_payment exige une clé sk_test_
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Session expirée ou en statut terminal
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/products:
    get:
      tags: [Produits]
      operationId: listProducts
      summary: Lister les produits
      parameters:
        - { name: sku, in: query, schema: { type: string }, description: Filtre exact }
        - $ref: "#/components/parameters/Limit500"
      responses:
        "200":
          description: Produits (hors supprimés, triés par nom)
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Produits]
      operationId: createProduct
      summary: Créer un produit
      description: Collision de `sku` → `409` + `existing_product_id` (upsert ergonomique).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductInput" }
      responses:
        "201":
          description: Produit créé
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: SKU déjà utilisé
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  existing_product_id: { type: integer }
  /api/v1/products/{id}:
    get:
      tags: [Produits]
      operationId: retrieveProduct
      summary: Récupérer un produit
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Produit
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Produits]
      operationId: updateProduct
      summary: Mettre à jour un produit (partiel)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductInput" }
      responses:
        "200":
          description: Produit mis à jour
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Produits]
      operationId: deleteProduct
      summary: Supprimer un produit (logique)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200": { $ref: "#/components/responses/Deleted" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/customers:
    get:
      tags: [Clients]
      operationId: listCustomers
      summary: Lister les clients
      description: Hors archivés, triés par nom, dans le mode (live/test) de la clé.
      parameters:
        - { name: email, in: query, schema: { type: string }, description: Filtre exact }
        - { name: externalId, in: query, schema: { type: string }, description: Filtre exact }
        - $ref: "#/components/parameters/Limit500"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Clients (id décroissant)
          content:
            application/json:
              schema:
                type: object
                properties:
                  customers:
                    type: array
                    items: { $ref: "#/components/schemas/Customer" }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Clients]
      operationId: createCustomer
      summary: Créer un client
      description: |
        `name` requis (ou `firstName` + `lastName`). Collision d'`externalId`
        → `409` + `existing_customer_id` (upsert ergonomique).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "201":
          description: Client créé
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: externalId déjà utilisé
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  existing_customer_id: { type: integer }
  /api/v1/customers/{id}:
    get:
      tags: [Clients]
      operationId: retrieveCustomer
      summary: Récupérer un client
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Client
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Clients]
      operationId: updateCustomer
      summary: Mettre à jour un client (partiel)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerInput" }
      responses:
        "200":
          description: Client mis à jour
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Clients]
      operationId: deleteCustomer
      summary: Archiver un client (suppression logique)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200": { $ref: "#/components/responses/Deleted" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/invoices:
    get:
      tags: [Factures]
      operationId: listInvoices
      summary: Lister les factures
      parameters:
        - { name: status, in: query, schema: { $ref: "#/components/schemas/InvoiceStatus" } }
        - { name: customerId, in: query, schema: { type: integer } }
        - { name: number, in: query, schema: { type: string }, description: Filtre exact }
        - $ref: "#/components/parameters/Limit500"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Factures (sans lignes), id décroissant
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items: { $ref: "#/components/schemas/Invoice" }
                  has_more: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Factures]
      operationId: createInvoice
      summary: Créer une facture
      description: |
        Brouillon par défaut ; `finalize: true` émet immédiatement
        (numérotation légale, écritures comptables, lien de paiement).
        Validation stricte des lignes : une ligne invalide → `422` avec son
        index. `Idempotency-Key` rend l'appel rejouable 24 h.
        Webhooks émis : `invoice.created` (+ `invoice.finalized` si émise).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceCreate" }
      responses:
        "201":
          description: Facture créée (avec lignes)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "200":
          description: Rejeu idempotent — facture déjà créée renvoyée telle quelle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: Ligne invalide (erreur ciblée `lines[i].champ`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/invoices/{id}:
    get:
      tags: [Factures]
      operationId: retrieveInvoice
      summary: Récupérer une facture (avec lignes)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Facture
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/invoices/{id}/finalize:
    post:
      tags: [Factures]
      operationId: finalizeInvoice
      summary: Émettre un brouillon (numérotation) — idempotent
      description: |
        Une facture déjà émise est renvoyée telle quelle (200). Une facture
        émise est immuable (art. 242 nonies A) : elle se corrige par un avoir.
        Webhook émis — `invoice.finalized`.
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Facture émise
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Facture annulée — non émissible
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/invoices/{id}/send:
    post:
      tags: [Factures]
      operationId: sendInvoice
      summary: Envoyer la facture par email (émet si brouillon)
      description: |
        Email avec PDF Factur-X joint et bouton de paiement. Destinataire :
        `to`, sinon l'email du client. Webhooks émis — `invoice.sent`
        (+ `invoice.finalized` si c'était un brouillon).
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                to: { type: string, format: email, description: "Défaut : email du client" }
                subject: { type: string }
                message: { type: string }
      responses:
        "200":
          description: Facture envoyée (`sentTo` ajouté à la réponse)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Facture annulée — non envoyable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Destinataire manquant ou invalide
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Échec d'envoi de l'email — réessayer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/invoices/{id}/mark_paid:
    post:
      tags: [Factures]
      operationId: markInvoicePaid
      summary: Régler hors ligne (virement, chèque…) — idempotent
      description: |
        Pour la réconciliation bancaire d'un ERP. Une facture déjà payée est
        renvoyée telle quelle (200). Un brouillon est émis (numéroté) au
        passage. Webhook émis — `invoice.paid` (`via: api`, avec `method` et
        `reference`).
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reference: { type: string, description: Référence du règlement (n° de virement…) }
                method: { type: string, enum: [virement, cheque, especes, autre], default: virement }
                paidAt: { type: string, format: date-time, description: "Défaut : maintenant" }
      responses:
        "200":
          description: Facture payée
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Facture annulée, ou avoir
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/invoices/{id}/credit_note:
    post:
      tags: [Factures]
      operationId: createCreditNote
      summary: Créer un avoir TOTAL sur une facture émise
      description: |
        L'avoir reprend les lignes de la facture mère (`parentInvoiceId`).
        Brouillon par défaut ; `finalize: true` l'émet (numéro `AV-*`,
        écritures d'extourne, événement « credited » sur la facture mère).
        L'avoir partiel n'est pas encore disponible. Webhooks émis —
        `invoice.created` (+ `invoice.finalized` si émis), avec
        `parent_invoice_id`.
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, description: Motif de l'avoir }
                finalize: { type: boolean, default: false }
      responses:
        "201":
          description: Avoir créé (docType avoir)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Cible non émise / déjà intégralement soldée par avoir / cible non-facture
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/invoices/{id}/pdf:
    get:
      tags: [Factures]
      operationId: invoicePdf
      summary: PDF Factur-X (factures émises)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: PDF/A-3 avec XML CII embarqué
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Brouillon — émettre la facture d'abord
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/quotes:
    get:
      tags: [Devis]
      operationId: listQuotes
      summary: Lister les devis
      parameters:
        - { name: status, in: query, schema: { $ref: "#/components/schemas/QuoteStatus" } }
        - { name: customerId, in: query, schema: { type: integer } }
        - $ref: "#/components/parameters/Limit500"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Devis (id décroissant)
          content:
            application/json:
              schema:
                type: object
                properties:
                  quotes:
                    type: array
                    items: { $ref: "#/components/schemas/Quote" }
                  has_more: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Devis]
      operationId: createQuote
      summary: Créer un devis (numéroté DEV-* immédiatement)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteCreate" }
      responses:
        "201":
          description: Devis créé (avec lignes et publicUrl de signature)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: Ligne invalide (erreur ciblée `lines[i].champ`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/quotes/{id}:
    get:
      tags: [Devis]
      operationId: retrieveQuote
      summary: Récupérer un devis (avec lignes)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Devis
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/quotes/{id}/send:
    post:
      tags: [Devis]
      operationId: sendQuote
      summary: Envoyer le devis par email (PDF + lien de signature)
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                to: { type: string, format: email, description: "Défaut : email du client" }
                subject: { type: string }
                message: { type: string }
      responses:
        "200":
          description: Devis envoyé (`sentTo` ajouté, statut sent si brouillon)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Destinataire manquant ou invalide
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Échec d'envoi de l'email — réessayer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/v1/quotes/{id}/convert:
    post:
      tags: [Devis]
      operationId: convertQuote
      summary: Accepter et convertir en facture émise — idempotent
      description: |
        Même règle que la signature en ligne : la facture émise est emailée au
        client avec son lien de paiement, et hérite du livemode du devis.
        Un devis déjà converti renvoie l'existant (200).
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "201":
          description: Converti — `{ invoiceId, quote }`
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoiceId: { type: [integer, "null"] }
                  quote: { $ref: "#/components/schemas/Quote" }
        "200":
          description: Déjà converti — renvoie l'existant
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoiceId: { type: [integer, "null"] }
                  quote: { $ref: "#/components/schemas/Quote" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/quotes/{id}/pdf:
    get:
      tags: [Devis]
      operationId: quotePdf
      summary: PDF du devis
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: PDF
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/payments:
    get:
      tags: [Paiements]
      operationId: listPayments
      summary: Lister les encaissements
      description: Flux réels uniquement — une clé de test reçoit toujours une liste vide.
      parameters:
        - { name: invoiceId, in: query, schema: { type: integer } }
        - { name: network, in: query, schema: { type: string }, description: "CB | bank_transfer | réseau crypto" }
        - $ref: "#/components/parameters/Limit500"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Paiements (id décroissant)
          content:
            application/json:
              schema:
                type: object
                properties:
                  payments:
                    type: array
                    items: { $ref: "#/components/schemas/Payment" }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/payments/{id}:
    get:
      tags: [Paiements]
      operationId: retrievePayment
      summary: Récupérer un encaissement
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Paiement
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/webhook_endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: Lister les endpoints
      responses:
        "200":
          description: Endpoints (signingSecret jamais réexposé)
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Créer un endpoint
      description: |
        URL `https://` publique obligatoire (hôtes internes/privés refusés —
        anti-SSRF). `events` vide = tous les événements. Le `signingSecret`
        n'est renvoyé qu'ICI — stockez-le immédiatement.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                name: { type: string, maxLength: 64 }
                events:
                  type: array
                  items: { type: string }
                  description: "ex. invoice.paid, payment.session.succeeded — vide = tous"
      responses:
        "201":
          description: Endpoint créé (avec signingSecret, une seule fois)
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/webhook_endpoints/{id}:
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Modifier un endpoint (enabled / name / events)
      description: L'URL ne se modifie pas — supprimer puis recréer.
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                name: { type: string, maxLength: 64 }
                events:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Endpoint modifié
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Supprimer un endpoint
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200": { $ref: "#/components/responses/Deleted" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/webhook_endpoints/{id}/rotate_secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookEndpointSecret
      summary: Régénérer le secret de signature
      description: |
        Un nouveau secret est généré et renvoyé UNE fois ; l'ancien cesse
        immédiatement de signer. En cas de fuite, ou périodiquement par hygiène.
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Endpoint avec son nouveau signingSecret (affiché une fois)
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/webhook_endpoints/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhookEndpoint
      summary: Envoyer une livraison d'essai signée
      description: "Payload d'exemple (`data.livemode: false`) — valide votre vérification de signature sans toucher aux données réelles."
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, default: ping }
      responses:
        "201":
          description: Livraison d'essai créée et tentée
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/webhook_deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Journal des livraisons
      parameters:
        - { name: event, in: query, schema: { type: string } }
        - { name: endpointId, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Livraisons (id décroissant)
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/webhook_deliveries/{id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhookDelivery
      summary: Rejouer une livraison
      parameters: [ { $ref: "#/components/parameters/PathId" } ]
      responses:
        "200":
          description: Livraison retentée
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

webhooks:
  delivery:
    post:
      summary: Enveloppe de chaque livraison webhook Nexus
      description: |
        POST JSON signé — `X-Nexus-Signature: t=<unix>,v1=<HMAC-SHA256 hex du
        corps brut>`, `X-Nexus-Event`, `X-Nexus-Delivery` (dédupliquez avec).
        Répondez 2xx ; sinon retries 1 min → 5 min → 30 min → 2 h → 12 h → 24 h.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  description: |
                    payment.session.created | payment.session.succeeded |
                    payment.session.failed | payment.cb.succeeded |
                    payment.refunded | payment.crypto.received |
                    invoice.created | invoice.finalized | invoice.sent |
                    invoice.paid | invoice.cancelled | invoice.payment_failed |
                    quote.created | quote.sent | quote.accepted |
                    dispute.created | dispute.updated | dispute.closed
                data:
                  type: object
                  description: Payload propre à l'événement — porte `livemode`.
                timestamp: { type: integer, description: Epoch Unix }
                delivery_id: { type: integer }
      responses:
        "200":
          description: Accusé de réception (tout 2xx)

components:
  securitySchemes:
    secretKey:
      type: http
      scheme: bearer
      description: Clé secrète `sk_live_…` / `sk_test_…` — serveur uniquement.
  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    Limit500:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 128 }
      description: Rend le POST rejouable 24 h sans doublon (rejeu → 200).
    StartingAfter:
      name: starting_after
      in: query
      schema: { type: integer }
      description: "Curseur : id du dernier élément reçu — renvoie la page suivante (listes triées id décroissant, has_more dans la réponse)."
  responses:
    BadRequest:
      description: Requête invalide
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Clé absente, invalide ou révoquée
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "Scope insuffisant — `{ error: insufficient_scope, required }`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Ressource introuvable (ou d'un autre marchand / autre mode live-test)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Deleted:
      description: Suppression confirmée
      content:
        application/json:
          schema:
            type: object
            properties:
              deleted: { type: boolean }
              id: { type: integer }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
      additionalProperties: true
    InvoiceStatus:
      type: string
      enum: [draft, open, paid, cancelled, refunded]
    QuoteStatus:
      type: string
      enum: [draft, sent, accepted, refused, expired]
    QuoteLineInput:
      type: object
      required: [designation, unitPriceCents]
      properties:
        designation: { type: string }
        description: { type: string }
        unit: { type: string, default: u }
        quantity: { type: number, default: 1 }
        unitPriceCents: { type: integer }
        discountPercent: { type: number }
        vatRate: { type: number, default: 20 }
    QuoteCreate:
      type: object
      required: [lines]
      properties:
        customerId: { type: integer, description: OU `customer` inline }
        customer:
          allOf: [ { $ref: "#/components/schemas/CustomerInput" } ]
          description: Retrouvé par externalId puis email ; créé sinon.
        lines:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/QuoteLineInput" }
        description: { type: string }
        currency: { type: string, default: EUR }
        globalDiscountCents: { type: integer }
        validityDate: { type: string, description: YYYY-MM-DD }
        acceptedMethods:
          type: array
          items: { type: string, enum: [cb, crypto, especes] }
    Quote:
      type: object
      properties:
        id: { type: integer }
        number: { type: [string, "null"], description: DEV-* (TEST-DEV-* en mode test) }
        status: { $ref: "#/components/schemas/QuoteStatus" }
        customerId: { type: [integer, "null"] }
        customerName: { type: [string, "null"] }
        description: { type: [string, "null"] }
        currency: { type: string }
        netCents: { type: integer }
        vatCents: { type: integer }
        grossCents: { type: integer }
        globalDiscountCents: { type: integer }
        issuedAt: { type: string, format: date-time }
        validityDate: { type: [string, "null"] }
        isExpired: { type: boolean }
        convertedInvoiceId: { type: [integer, "null"] }
        convertedAt: { type: [string, "null"] }
        signerName: { type: [string, "null"] }
        signedAt: { type: [string, "null"], format: date-time }
        acceptedMethods: { type: [array, "null"], items: { type: string } }
        livemode: { type: boolean }
        publicUrl:
          type: [string, "null"]
          description: Page hébergée « voir et signer » — partageable.
        lines:
          type: array
          items:
            type: object
            properties:
              designation: { type: string }
              description: { type: [string, "null"] }
              unit: { type: string }
              quantity: { type: number }
              unitPriceCents: { type: integer }
              discountPercent: { type: number }
              vatRate: { type: number }
              lineTotalHtCents: { type: integer }
        sentTo: { type: string, description: Présent dans la réponse de /send }
    Product:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        category: { type: [string, "null"] }
        description: { type: [string, "null"] }
        sku: { type: [string, "null"] }
        unitPriceCents: { type: integer, description: Prix unitaire HT en centimes }
        vatRate: { type: number }
        grossPriceCents: { type: integer, description: Prix TTC calculé }
        stockTracking: { type: boolean }
        stockQuantity: { type: [integer, "null"] }
        availableForCheckout: { type: boolean }
        purchasable: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ProductInput:
      type: object
      properties:
        name: { type: string, description: Requis à la création }
        category: { type: string }
        description: { type: string }
        sku: { type: string }
        unitPriceCents: { type: integer }
        vatRate: { type: number }
        stockTracking: { type: boolean }
        stockQuantity: { type: [integer, "null"] }
        availableForCheckout: { type: boolean }
    Customer:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        type: { type: string, enum: [company, individual] }
        firstName: { type: [string, "null"] }
        lastName: { type: [string, "null"] }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        siren: { type: [string, "null"] }
        siret: { type: [string, "null"] }
        tvaIntra: { type: [string, "null"] }
        externalId: { type: [string, "null"] }
        country: { type: string }
        tags:
          type: array
          items: { type: string }
        livemode: { type: boolean }
        address:
          type: object
          properties:
            street: { type: [string, "null"] }
            postcode: { type: [string, "null"] }
            city: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
    CustomerInput:
      type: object
      properties:
        name: { type: string, description: "Requis à la création (ou firstName + lastName)" }
        type: { type: string, enum: [company, individual] }
        firstName: { type: string }
        lastName: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        siren: { type: string }
        siret: { type: string }
        tvaIntra: { type: string }
        externalId: { type: string }
        country: { type: string, description: ISO-2 }
        tags:
          type: array
          items: { type: string }
        address:
          type: object
          properties:
            street: { type: string }
            postcode: { type: string }
            city: { type: string }
    InvoiceLine:
      type: object
      properties:
        designation: { type: string }
        description: { type: [string, "null"] }
        unit: { type: string }
        quantity: { type: number }
        unitPriceCents: { type: integer }
        discountPercent: { type: number }
        vatRate: { type: number }
        lineTotalHtCents: { type: integer }
        kind: { type: string, enum: [good, service] }
    InvoiceLineInput:
      type: object
      properties:
        designation: { type: string, description: Requis sauf si productId }
        description: { type: string }
        unit: { type: string, default: u }
        quantity: { type: number, default: 1 }
        unitPriceCents: { type: integer, description: Requis sauf si productId }
        discountPercent: { type: number }
        vatRate: { type: number, default: 20 }
        kind: { type: string, enum: [good, service], default: service }
        productId: { type: integer, description: Ligne catalogue }
    InvoiceCreate:
      type: object
      required: [lines]
      properties:
        customerId: { type: integer, description: OU `customer` inline }
        customer:
          allOf: [ { $ref: "#/components/schemas/CustomerInput" } ]
          description: Retrouvé par externalId puis email ; créé sinon.
        lines:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/InvoiceLineInput" }
        finalize: { type: boolean, default: false }
        currency: { type: string, default: EUR }
        description: { type: string }
        dueDate: { type: string }
        deliveryDate: { type: string }
        serviceStartDate: { type: string }
        serviceEndDate: { type: string }
        purchaseOrderRef: { type: string }
        paymentTerms: { type: string }
        globalDiscountCents: { type: integer }
        depositCents: { type: integer }
        acceptedMethods:
          type: array
          items: { type: string, enum: [cb, crypto, especes] }
    Invoice:
      type: object
      properties:
        id: { type: integer }
        number: { type: [string, "null"], description: "null tant que brouillon — TEST-* en mode test" }
        docType: { type: string, enum: [facture, avoir] }
        parentInvoiceId: { type: [integer, "null"], description: Facture mère (avoirs) }
        creditReason: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/InvoiceStatus" }
        customerId: { type: [integer, "null"] }
        customerName: { type: [string, "null"] }
        description: { type: [string, "null"] }
        currency: { type: string }
        subtotalHtCents: { type: integer }
        globalDiscountCents: { type: integer }
        netCents: { type: integer }
        vatCents: { type: integer }
        grossCents: { type: integer }
        depositCents: { type: integer }
        netToPayCents: { type: integer }
        acceptedMethods:
          type: array
          items: { type: string }
        purchaseOrderRef: { type: [string, "null"] }
        paymentTerms: { type: [string, "null"] }
        issuedAt: { type: string, format: date-time }
        dueDate: { type: [string, "null"], format: date-time }
        paidAt: { type: [string, "null"], format: date-time }
        serviceStartDate: { type: [string, "null"] }
        serviceEndDate: { type: [string, "null"] }
        livemode: { type: boolean }
        paymentUrl:
          type: [string, "null"]
          description: Page de paiement hébergée — null si brouillon, payée, avoir ou mode test.
        lines:
          type: array
          items: { $ref: "#/components/schemas/InvoiceLine" }
          description: Présent sur retrieve/create/finalize/send (pas en liste).
        sentTo: { type: string, description: Présent dans la réponse de /send }
    CheckoutSessionCreate:
      type: object
      properties:
        amount: { type: integer, description: Centimes TTC — ignoré si produit catalogue / line_items }
        currency: { type: string, default: EUR }
        description: { type: string }
        customer_email: { type: string, format: email }
        customer_name: { type: string }
        customer_address:
          type: object
          properties:
            street: { type: string }
            postal_code: { type: string }
            city: { type: string }
            country: { type: string }
        customer_siret: { type: string }
        customer_vat_number: { type: string }
        customer_external_id: { type: string }
        success_url: { type: string, maxLength: 500 }
        cancel_url: { type: string, maxLength: 500 }
        metadata:
          type: object
          description: Restitué dans réponses et webhooks. product_id / sku = produit catalogue.
        line_items:
          type: array
          maxItems: 50
          items:
            type: object
            properties:
              product_id: { type: integer }
              sku: { type: string }
              designation: { type: string }
              unit_price_cents: { type: integer }
              vat_rate: { type: number, default: 20 }
              quantity: { type: number, default: 1 }
              unit: { type: string, default: u }
        auto_invoice: { type: boolean, default: true }
        accepted_methods:
          type: array
          items: { type: string, enum: [cb, crypto] }
    CheckoutSession:
      type: object
      properties:
        id: { type: string, examples: [ps_00000042] }
        token: { type: string }
        url: { type: string, description: Page de paiement hébergée (24 h) }
        amount: { type: integer }
        currency: { type: string }
        description: { type: [string, "null"] }
        customer_email: { type: [string, "null"] }
        customer_name: { type: [string, "null"] }
        success_url: { type: [string, "null"] }
        cancel_url: { type: [string, "null"] }
        metadata: { type: object }
        line_items: { type: [array, "null"] }
        accepted_methods:
          type: array
          items: { type: string }
        status: { type: string, enum: [pending, succeeded, failed, expired, refunded] }
        auto_invoice: { type: boolean }
        livemode: { type: boolean }
        invoice_id: { type: [integer, "null"] }
        paid_at: { type: [string, "null"], format: date-time }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    Payment:
      type: object
      properties:
        id: { type: integer }
        status: { type: string, enum: [succeeded, pending, refunded] }
        grossAmountCents: { type: integer }
        feeCents: { type: integer, description: Commission Nexus }
        netAmountCents: { type: integer, description: Net reversé au marchand }
        currency: { type: string }
        network: { type: string, description: "CB | bank_transfer | réseau crypto" }
        invoiceId: { type: [integer, "null"] }
        invoiceNumber: { type: [string, "null"] }
        reference: { type: [string, "null"], description: Référence interne du paiement }
        buyer: { type: [string, "null"] }
        stripePaymentIntentId: { type: [string, "null"] }
        txHash: { type: [string, "null"], description: Hash on-chain (crypto) }
        occurredAt: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        url: { type: string }
        enabledEvents:
          type: array
          items: { type: string }
          description: Vide = tous les événements
        enabled: { type: boolean }
        signingSecret:
          type: [string, "null"]
          description: Renvoyé UNIQUEMENT à la création
        lastDeliveryAt: { type: [string, "null"], format: date-time }
        lastStatusCode: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
    WebhookDelivery:
      type: object
      properties:
        id: { type: integer }
        endpointId: { type: integer }
        endpointName: { type: string }
        endpointUrl: { type: string }
        event: { type: string }
        status: { type: string, enum: [pending, delivered, failed] }
        statusCode: { type: [integer, "null"] }
        attempts: { type: integer }
        lastError: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        deliveredAt: { type: [string, "null"], format: date-time }
        nextRetryAt: { type: [string, "null"], format: date-time }
        payload: { type: object }
