openapi: 3.0.3
info:
  title: ITICK Partner API
  description: |
    API B2B d'ITICK pour les partenaires (PDP, logiciels comptables).

    **Modele** : Comme Plaid pour les donnees bancaires, ITICK est l'infrastructure
    de donnees certifiees pour les factures.

    ## Authentification

    Toutes les requêtes nécessitent une clé API partenaire dans le header `Authorization` :
    ```
    Authorization: Bearer <votre clé API>
    ```

    La clé est une **chaîne opaque** : transmettez-la telle quelle, sans jamais
    l'analyser ni la recomposer. Indication non contractuelle : une clé commençant
    par `itick_sandbox_` cible le bac à sable (sandbox), les autres la production.
    Les clés historiques au format `itick_<providerId>_<secret>` restent acceptées.

    ## Environnements

    - **Sandbox** : la clé est remise à l'inscription sur le portail partenaire
      (https://itick.fr/partenaires/inscription) et gérable depuis le portail
      (https://app.itick.fr/partner). Les reçus poussés en sandbox sont cloisonnés :
      ils ne touchent jamais les données de production.
    - **Production** : la clé s'obtient en self-serve via
      `POST /partner/onboarding/request-prod` (depuis le portail partenaire), qui
      exige TROIS conditions : checklist d'onboarding à 100 %, au moins un reçu
      réellement poussé en sandbox via l'API, et au moins une livraison de webhook
      réussie (livraison réelle `delivered` ou ping de test répondu en 2xx —
      c'est l'étape « Webhook testé » de la checklist). Sinon : `400` avec, dans
      `details[]`, la liste des conditions manquantes. La clé de production n'est
      affichée qu'une seule fois, avec des scopes restreints par défaut.
    - **L'environnement est celui de la clé API** : une clé sandbox écrit et lit
      en sandbox, une clé production en production, sans exception. Chaque
      réponse `201` d'ingestion (et chaque item `created` d'un lot) renvoie le
      champ `environment` : vous voyez toujours où vous venez d'écrire.
    - **En-tête `X-iTick-Environment: sandbox`** (portail partenaire uniquement,
      session JWT) : le portail l'envoie pour travailler en sandbox — onglet
      Sandbox, checklist d'onboarding, liste des reçus. Il agit en **dé-escalade
      seule** : seule la valeur exacte `sandbox` est honorée, toute autre valeur
      (y compris `production`) laisse la session en production. Il est
      **ignoré pour les clés API** : aucun en-tête ne peut faire lire ou écrire
      la production à une clé sandbox, ni l'inverse.

    ## Rate Limiting

    Deux compteurs coexistent, et vos reponses portent DEUX familles d'en-tetes.
    Celui qui vous borne est le premier :

    - **Par partenaire — c'est votre budget : 1000 requetes / 15 minutes.**
      En-tetes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`.
      `X-RateLimit-Reset` est un **horodatage Unix en secondes** (date de
      remise a zero). Un depassement renvoie `429` avec `Retry-After`.
    - **Par adresse IP — filet anti-abus : 10 000 requetes / 15 minutes.**
      En-tetes standard `RateLimit-Limit`, `RateLimit-Remaining`,
      `RateLimit-Reset`, `RateLimit-Policy`. Attention : `RateLimit-Reset` est
      un **nombre de secondes restantes**, pas un horodatage. Ce plafond vise
      le brute-force de cles ; un integrateur normal ne l'atteint jamais.

    ⚠️ Surveillez `X-RateLimit-Remaining` (par partenaire). Les deux compteurs
    sont independants : ils ne decroissent pas au meme rythme, et c'est normal.

    ## Certifications

    Les donnees retournees sont conformes aux normes suivantes :
    - **EN16931** : Champs de facturation electronique europeenne
    - **RFC3161** : Horodatage certifie (preuve temporelle)
    - **PAF** : Piste d'Audit Fiable (tracabilite complete)
  version: 1.0.0
  contact:
    name: ITICK
    email: contact@itick.fr

servers:
  - url: https://api.itick.fr/itick
    description: Production
  - url: http://localhost:3000/itick
    description: Development

security:
  - ApiKeyAuth: []

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: "Clé API partenaire — chaîne opaque, à transmettre telle quelle et à ne jamais analyser ni recomposer. Indication non contractuelle : une clé commençant par itick_sandbox_ cible le bac à sable, les autres la production."

  schemas:
    PushReceiptItem:
      type: object
      required: [name, quantity, unitPriceHt, vatRate]
      properties:
        name: { type: string, maxLength: 255, example: 'Expresso' }
        description: { type: string, maxLength: 255 }
        quantity: { type: number, minimum: 1, example: 2 }
        unitPriceHt: { type: number, minimum: 0, description: 'Prix unitaire HT', example: 1.5 }
        vatRate: { type: number, minimum: 0, description: 'Taux de TVA en % (ex. 20, 10, 5.5)', example: 10 }
        discount: { type: number, minimum: 0, description: 'Remise sur la ligne' }
        sku: { type: string, maxLength: 255, description: 'Référence article interne' }
        unitOfMeasure: { type: string, maxLength: 255, description: 'Code unité EN16931 (C62=unité, KGM=kg, LTR=litre)' }
        taxCategoryCode: { type: string, maxLength: 255, description: 'Code catégorie TVA (S=standard, E=exempt, Z=zéro)' }
        itemIdentifier: { type: string, maxLength: 255, description: 'Code article vendeur (EN16931)' }
        buyerItemIdentifier: { type: string, maxLength: 255, description: 'Code article acheteur (EN16931)' }
    PushAddress:
      type: object
      description: 'Adresse (EN16931) — tous les champs sont facultatifs.'
      properties:
        street1: { type: string, maxLength: 255 }
        street2: { type: string, maxLength: 255 }
        city: { type: string, maxLength: 255 }
        postalCode: { type: string, maxLength: 255 }
        country: { type: string, maxLength: 255 }
    PushReceiptRequest:
      type: object
      description: |
        Corps unique de l'ingestion. Le mode est détecté d'après les champs
        envoyés (aucun champ « mode » à fournir) :
        - `items` présent → **mode structuré** ;
        - `items` absent + `documentPdf` présent → **mode simple** (OCR) ;
        - ni l'un ni l'autre → `400 MODE_REQUIRED`.

        Les obligations dépendent du mode et ne sont volontairement pas
        exprimées en `required:` (deux jeux de règles sur un même corps
        rendraient les SDK générés inutilisables) ; elles sont vérifiées par le
        serveur, qui répond `400 VALIDATION_ERROR` détaillé champ par champ :
        - **mode structuré** : `date`, `merchantSiret`, `merchantName`,
          `merchantEmail`, `merchantPhone` et `items` sont obligatoires ;
        - **mode simple** : seul `documentPdf` est obligatoire ; `date`,
          `merchantSiret`, `merchantName`, `merchantEmail`, `merchantPhone`,
          `totalTtc` deviennent des indices OCR facultatifs (validés s'ils sont
          fournis).
      x-required-structured: [date, merchantSiret, merchantName, merchantEmail, merchantPhone, items]
      x-required-document: [documentPdf]
      properties:
        # --- Mode structuré (obligatoires quand items est présent) ---
        date: { type: string, format: date-time, description: 'Obligatoire en mode structuré ; indice OCR facultatif en mode simple (prioritaire sur la date lue)' }
        merchantSiret:
          type: string
          maxLength: 255
          description: >-
            SIRET du commerçant — obligatoire en mode structuré. 14 chiffres
            avec une clé de Luhn valide. ⚠️ L'ingestion ne verifie PAS la clé
            de Luhn (seul le format chaîne est contrôlé) : un SIRET erroné est
            accepté ici, mais il devient la clé d'unicité du commerçant et
            l'identité fiscale vendeur est ensuite OMISE du Factur-X émis,
            sans erreur remontée. Envoyez un SIRET réel.
        merchantName: { type: string, maxLength: 255, description: 'Obligatoire en mode structuré ; indice OCR facultatif en mode simple' }
        merchantEmail: { type: string, format: email, description: 'Obligatoire en mode structuré — sert au matching / rattachement du commerçant' }
        merchantPhone: { type: string, maxLength: 255, description: 'Obligatoire en mode structuré' }
        items:
          type: array
          minItems: 1
          description: 'Présence = mode structuré. ITICK calcule totaux et TVA depuis ces lignes.'
          items: { $ref: '#/components/schemas/PushReceiptItem' }
        # --- Mode simple (document) ---
        documentPdf:
          type: string
          format: byte
          description: 'PDF / JPEG / PNG / WebP encodé en base64 strict (max 10 Mo décodés). Seul champ obligatoire du mode simple ; en mode structuré, joint au reçu comme document original.'
        totalTtc: { type: number, minimum: 0, description: 'Mode simple : indice OCR, prioritaire sur le montant lu ; permet un brouillon minimal si le document est illisible (422). Ignoré en mode structuré (totaux recalculés depuis items).' }
        # --- Client final ---
        customerEmail: { type: string, format: email, description: 'Rattachement au client final (ou salle d''attente + QR)' }
        customerPhone: { type: string, maxLength: 255 }
        # --- Divers ---
        externalId: { type: string, maxLength: 255, description: 'Votre identifiant unique de ticket — LA clé d’idempotence recommandée : déduplication scopée à votre compte et à l’environnement (409 si déjà vu) ; sa présence désactive le filet heuristique « même montant, même jour ».' }
        generateQr: { type: boolean, description: 'Génère un QR de réclamation (utile si le client n''a pas de compte)' }
        paymentMethod: { type: string, enum: [card, cash, mobile, check, transfer, other], description: 'Moyen de paiement' }
        operatorId: { type: string, maxLength: 255, description: 'Identifiant opérateur/caissier' }
        # --- Enrichissement commerçant (utilisé si le commerçant n''existe pas encore ou est incomplet) ---
        merchantLogo: { type: string, maxLength: 255, description: 'Logo du commerçant (URL)' }
        merchantVatNumber: { type: string, maxLength: 255, description: 'Numéro de TVA intracommunautaire du commerçant' }
        merchantAddress: { $ref: '#/components/schemas/PushAddress' }
        merchantNafCode: { type: string, maxLength: 255, description: 'Code NAF/APE du commerçant' }
        merchantLegalForm: { type: string, maxLength: 255, description: 'Forme juridique du commerçant' }
        # --- EN16931 : acheteur / vendeur (facultatifs) ---
        buyerName: { type: string, maxLength: 255 }
        buyerSiret: { type: string, maxLength: 255 }
        buyerVatNumber: { type: string, maxLength: 255 }
        buyerAddress: { $ref: '#/components/schemas/PushAddress' }
        sellerVatNumber: { type: string, maxLength: 255 }
        sellerAddress: { $ref: '#/components/schemas/PushAddress' }
        # --- EN16931 : références, dates, paiement (facultatifs) ---
        deliveryDate: { type: string, format: date-time }
        dueDate: { type: string, format: date-time }
        paymentAccountIban: { type: string, maxLength: 255, description: 'IBAN du vendeur (EN16931)' }
        paymentAccountBic: { type: string, maxLength: 255, description: 'BIC du vendeur (EN16931)' }
        purchaseOrderRef: { type: string, maxLength: 255, description: 'Référence commande (EN16931)' }
        contractRef: { type: string, maxLength: 255, description: 'Référence contrat (EN16931)' }
        documentTypeCode: { type: string, maxLength: 255, description: 'EN16931 (380=facture, 381=avoir)' }
        taxCategoryCode: { type: string, maxLength: 255, description: 'Code catégorie fiscale (S=standard, E=exempt, Z=zéro)' }
        taxExemptionReason: { type: string, maxLength: 255, description: 'Motif d''exonération TVA' }
        paymentTerms: { type: string, maxLength: 255, description: 'Conditions de paiement (EN16931)' }
    PushReceiptResult:
      type: object
      properties:
        receiptId: { type: string, format: uuid }
        invoiceNumber: { type: string, example: '73282932000074-00001' }
        matched: { type: boolean, description: 'true si rattaché à un utilisateur existant' }
        mode: { type: string, enum: [document], description: 'Présent en mode simple uniquement' }
        environment:
          type: string
          enum: [sandbox, production]
          description: "Environnement dans lequel le reçu vient d'être écrit (celui de la clé API, ou de la session portail en sandbox)."
        totals:
          type: object
          properties:
            totalHt: { type: number }
            totalVat: { type: number }
            totalTtc: { type: number }
            currency: { type: string, example: 'EUR' }
            vatBreakdown:
              type: array
              items:
                type: object
                properties:
                  rate: { type: number }
                  baseHt: { type: number }
                  vatAmount: { type: number }
        extraction:
          type: object
          nullable: true
          description: 'Mode simple : ce que l''OCR a lu (transparence).'
        suggestedCategoryId: { type: string, nullable: true }
        qrCode: { type: string, nullable: true, description: 'Data-URL du QR (si generateQr / client non rattaché)' }
        qrCodeUrl: { type: string, nullable: true }
        qrToken: { type: string, nullable: true }
    PushBatchResult:
      type: object
      properties:
        total: { type: integer }
        created: { type: integer }
        failed: { type: integer }
        results:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              status: { type: string, enum: [created, error] }
              receiptId: { type: string, format: uuid, nullable: true }
              invoiceNumber: { type: string, nullable: true }
              environment:
                type: string
                enum: [sandbox, production]
                nullable: true
                description: "Items `created` : environnement dans lequel le reçu vient d'être écrit."
              error: { type: string, nullable: true }
              message: { type: string, nullable: true }
    IngressError:
      type: object
      properties:
        error: { type: string, description: 'Code d''erreur stable (ex. MODE_REQUIRED, VALIDATION_ERROR, DUPLICATE_RECEIPT_DETECTED)' }
        message: { type: string, description: 'Message lisible' }
        details:
          type: array
          nullable: true
          items: { type: object }
    Receipt:
      type: object
      properties:
        id:
          type: string
          format: uuid
        externalId:
          type: string
          description: "Identifiant externe du provider"
          nullable: true
        invoiceNumber:
          type: string
          description: "Numero de facture sequentiel (SIRET-00001)"
        date:
          type: string
          format: date-time
        merchantId:
          type: string
          format: uuid
        merchantName:
          type: string
        currency:
          type: string
          example: EUR
        totalHt:
          type: number
          description: Montant hors taxes
        totalVat:
          type: number
          description: Montant TVA
        totalTtc:
          type: number
          description: Montant TTC
        vatBreakdown:
          type: array
          items:
            $ref: '#/components/schemas/VatBreakdown'
        items:
          type: array
          items:
            $ref: '#/components/schemas/ReceiptItem'
        paymentMethod:
          type: string
          enum: [card, cash, mobile, check, transfer, other]
        type:
          type: string
          enum: [PERSONAL, BUSINESS]
        chainHash:
          type: string
          description: "Hash SHA-256 intégrité"
        previousHash:
          type: string
          description: "Hash SHA-256 de l'entree precedente"
        chainIndex:
          type: integer
          description: "Position dans la chaine d'intégrité"
        buyerName:
          type: string
          nullable: true
          description: >-
            Nom de l'acheteur (EN16931). Vaut `Supprimé (RGPD)` — avec
            `buyerSiret`, `buyerVatNumber` et `buyerAddress` vidés — lorsque le
            client a supprimé le reçu de son coffre (depuis le 02/09/2026) ou
            exercé son droit à l'effacement (art. 17, via votre intégration) ;
            le document, lui, reste dans votre dépôt.
        buyerSiret:
          type: string
          nullable: true
        buyerVatNumber:
          type: string
          nullable: true
        buyerAddress:
          type: object
          nullable: true
          description: "Adresse complete de l'acheteur (EN16931)"
          properties:
            street1:
              type: string
            street2:
              type: string
            city:
              type: string
            postalCode:
              type: string
            country:
              type: string
        sellerSiret:
          type: string
          nullable: true
        sellerVatNumber:
          type: string
          nullable: true
        sellerAddress:
          type: object
          nullable: true
          description: "Adresse complete du vendeur (snapshot EN16931)"
          properties:
            street1:
              type: string
            street2:
              type: string
            city:
              type: string
            postalCode:
              type: string
            country:
              type: string
        deliveryDate:
          type: string
          format: date-time
          nullable: true
        dueDate:
          type: string
          format: date-time
          nullable: true
        paymentAccountIban:
          type: string
          nullable: true
          description: "IBAN du vendeur (EN16931)"
        paymentAccountBic:
          type: string
          nullable: true
          description: "BIC du vendeur (EN16931)"
        purchaseOrderRef:
          type: string
          nullable: true
          description: "Reference commande (EN16931)"
        contractRef:
          type: string
          nullable: true
          description: "Reference contrat (EN16931)"
        documentTypeCode:
          type: string
          description: "EN16931 (380=facture, 381=avoir)"
          nullable: true
        taxCategoryCode:
          type: string
          description: "Code categorie fiscale (S=standard, E=exempt, Z=zero)"
          nullable: true
        taxExemptionReason:
          type: string
          description: "Motif d'exoneration TVA"
          nullable: true
        tsaHash:
          type: string
          description: "Hash horodate RFC3161"
          nullable: true
        tsaIssuedAt:
          type: string
          format: date-time
          nullable: true
        operatorId:
          type: string
          description: "Identifiant operateur/caissier"
          nullable: true

    VatBreakdown:
      type: object
      properties:
        rate:
          type: number
          description: "Taux TVA (%)"
        baseHt:
          type: number
          description: "Base HT"
        vatAmount:
          type: number
          description: "Montant TVA"

    ReceiptItem:
      type: object
      properties:
        lineNumber:
          type: integer
        name:
          type: string
        quantity:
          type: number
        unitPriceHt:
          type: number
        vatRate:
          type: number
        totalHt:
          type: number
        totalTtc:
          type: number
        unitOfMeasure:
          type: string
          description: "Code unite EN16931 (C62=unite, KGM=kg, LTR=litre)"
          nullable: true
        taxCategoryCode:
          type: string
          description: "Code categorie TVA (S=standard, E=exempt, Z=zero)"
          nullable: true
        itemIdentifier:
          type: string
          description: "Code article vendeur"
          nullable: true
        buyerItemIdentifier:
          type: string
          description: "Code article acheteur"
          nullable: true

    Certificate:
      type: object
      properties:
        receiptId:
          type: string
          format: uuid
        timestamped:
          type: boolean
        hashAlgorithm:
          type: string
          example: SHA-256
        documentHash:
          type: string
        tsaUrl:
          type: string
        issuedAt:
          type: string
          format: date-time
        verified:
          type: boolean
        tsaResponse:
          type: string
          format: byte
          description: "Token RFC3161 encode en base64"

    AuditEntry:
      type: object
      properties:
        id:
          type: string
          format: uuid
        entityType:
          type: string
        entityId:
          type: string
        action:
          type: string
        userId:
          type: string
        timestamp:
          type: string
          format: date-time
        chainHash:
          type: string
        previousHash:
          type: string

    Merchant:
      type: object
      properties:
        merchantId:
          type: string
          format: uuid
        merchantName:
          type: string
        receiptCount:
          type: integer
        lastReceiptDate:
          type: string
          format: date-time

    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        events:
          type: array
          description: |
            Événements livrés :
            - `receipt.created` : un reçu vient d'être créé ;
            - `receipt.updated` : un reçu a été modifié ;
            - `receipt.deleted` : un reçu a été supprimé — par un administrateur,
              ou **par le client lui-même** depuis son coffre (depuis le
              02/09/2026) : dans ce second cas le document reste dans votre
              dépôt, mais il est détaché du client et ses coordonnées sont
              pseudonymisées (`buyerName` = `Supprimé (RGPD)`, `buyerSiret` /
              `buyerVatNumber` / `buyerAddress` vidés ; l'e-mail et le
              téléphone de contact, jamais exposés par cette API, deviennent
              respectivement un pseudonyme `removed-…@anonymized.itick.fr` et
              vide). Hors organisation, le QR du reçu reste réclamable par un
              nouveau client (vous recevrez alors `receipt.assigned`). Pour un
              reçu rattaché à une organisation : retiré par un membre, il reste
              dans l'organisation, ses QR sont révoqués et
              `POST /partner/receipts/{id}/qr` répond `409 RECEIPT_NOT_CLAIMABLE` ;
              retiré par un administrateur de l'organisation, il en sort et son
              QR reste réclamable. L'événement n'est émis qu'une fois : un second
              retrait est refusé (404) sans nouvel événement.
            - `receipt.assigned` : le reçu est rattaché à un utilisateur (réclamé
              via QR ou matché) — **l'événement de référence pour un POS** : il
              signifie que le ticket a atteint le coffre d'un vrai client.
          items:
            type: string
            enum: [receipt.created, receipt.updated, receipt.deleted, receipt.assigned]
        active:
          type: boolean
        environment:
          type: string
          enum: [sandbox, production]
          description: >-
            Environnement de l'endpoint. Un endpoint sandbox ne recoit QUE les
            evenements sandbox, un endpoint production QUE les evenements
            production (l'environnement est aussi emis dans le header
            X-iTick-Environment et le champ `environment` du payload).
            Herite a la creation de l'environnement de la cle API utilisee
            (portail : statut d'onboarding du compte).
        lastDeliveryAt:
          type: string
          format: date-time
          nullable: true
        lastDeliveryStatus:
          type: integer
          nullable: true
        failureCount:
          type: integer
          description: >-
            Nombre d'echecs de livraison consecutifs. A 5, l'endpoint est
            DESACTIVE automatiquement (active=false) et les evenements passent
            en dead_letter. Reprise : PUT /partner/webhooks/{id} avec
            {"active": true} (remet ce compteur a zero), puis rejouez les
            evenements en dead_letter via POST /partner/webhooks/events/{eventId}/replay.
        lastError:
          type: string
          nullable: true
          description: >-
            Cause du dernier echec de livraison (ex. "HTTP 500",
            "SSRF check failed: ...", message de timeout). null = la derniere
            livraison a reussi.
        createdAt:
          type: string
          format: date-time

    WebhookEventLog:
      type: object
      properties:
        id:
          type: string
          format: uuid
        partnerId:
          type: string
        endpointId:
          type: string
          format: uuid
        eventType:
          type: string
        payload:
          type: object
          description: Payload livre (event, timestamp, environment, data)
        status:
          type: string
          enum: [pending, delivered, failed, dead_letter]
        attempts:
          type: integer
        httpStatus:
          type: integer
          nullable: true
          description: Statut HTTP de la derniere tentative (null si erreur reseau/SSRF/timeout)
        lastError:
          type: string
          nullable: true
          description: Cause du dernier echec de livraison de CET evenement
        createdAt:
          type: string
          format: date-time
        deliveredAt:
          type: string
          format: date-time
          nullable: true
        deadLetteredAt:
          type: string
          format: date-time
          nullable: true
        nextAttemptAt:
          type: string
          format: date-time
          nullable: true
          description: Prochain essai planifie (null = aucun retry planifie)

    QrCodeResult:
      type: object
      properties:
        token:
          type: string
          description: "Token unique du QR code"
        qrCode:
          type: string
          description: "QR code encodé (data URL en standard, base64 brut en thermal)"
        qrCodeUrl:
          type: string
          format: uri
          description: "URL encodée dans le QR code (https://itick.fr/r/{id}?t={token})"
        expiresAt:
          type: string
          format: date-time
          description: "Date d'expiration du token (30 jours)"
        printText:
          type: string
          nullable: true
          description: "Texte d'accompagnement pour imprimante thermique (thermal uniquement)"
          example: "Scannez pour récupérer votre ticket dématérialisé"
        printUrl:
          type: string
          nullable: true
          description: "URL courte pour affichage texte sous le QR (thermal uniquement)"
          example: "itick.fr/r/{id}"

    PaginatedResponse:
      type: object
      properties:
        page:
          type: integer
        perPage:
          type: integer
        total:
          type: integer

    Error:
      type: object
      properties:
        error:
          type: string

    ChecklistStep:
      type: object
      description: Etat d'une des six etapes de la checklist d'onboarding.
      properties:
        completed:
          type: boolean
        completedAt:
          type: string
          format: date-time
          nullable: true

paths:
  /receipts/providers:
    post:
      summary: Pousser un reçu (réception ITICK)
      operationId: pushReceipt
      description: |
        **Le cœur de l'intégration.** Envoie un reçu/ticket à ITICK, qui le
        certifie (Factur-X, RFC3161, PAF) et le route vers le bon utilisateur
        final (matching par `customerEmail` / `customerPhone`, ou salle
        d'attente + QR si l'utilisateur n'a pas encore de compte).

        Deux modes, détectés automatiquement d'après le corps envoyé :

        - **Mode structuré** (recommandé) : vous fournissez `items[]`. ITICK
          calcule les totaux et la TVA, aucune lecture nécessaire. Chemin
          « qualité ».
        - **Mode simple** : vous fournissez seulement `documentPdf` (PDF ou
          image en base64) + `customerEmail`. ITICK lit le document par OCR.
          *« Quitte à ne recevoir que le PDF, nous on lit. »*

        `items` présent → structuré. `items` absent + `documentPdf` présent →
        simple. Ni l'un ni l'autre → `400 MODE_REQUIRED`.

        **Idempotence** : envoyez un header `Idempotency-Key` (UUID de votre
        côté) pour qu'un retry réseau ne crée pas de doublon — la réponse est
        rejouée 24 h à l'identique. `externalId` fournit une déduplication
        métier scopée à votre compte (409 si déjà vu).

        **Déduplication** : avec `externalId`, votre identifiant est la SEULE
        clé d'unicité (409 s'il est déjà vu dans le même environnement) — deux
        ventes distinctes au même montant le même jour passent. Sans
        `externalId`, ITICK applique un filet heuristique : même commerçant +
        même montant + même client (si fourni) le même jour ; sans client,
        même commerçant + même montant + même `date` à la seconde (signature
        d'un rejeu). **Fournissez toujours `externalId`.**
      tags: [Ingestion]
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 256 }
          description: Clé d'idempotence (rejeu 24 h). Recommandé sur tout POST.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PushReceiptRequest'
            examples:
              structuré:
                summary: Mode structuré (items)
                value:
                  date: '2026-07-06T10:00:00Z'
                  merchantSiret: '73282932000074'
                  merchantName: 'Le Café du Coin'
                  merchantEmail: 'contact@lecafe.fr'
                  merchantPhone: '+33100000000'
                  items:
                    - { name: 'Expresso', quantity: 2, unitPriceHt: 1.5, vatRate: 10 }
                  customerEmail: 'client@example.com'
              simple:
                summary: Mode simple (documentPdf)
                value:
                  documentPdf: 'JVBERi0xLjQK'
                  totalTtc: 12.5
                  customerEmail: 'client@example.com'
      responses:
        '201':
          description: Reçu créé et certifié
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PushReceiptResult'
              examples:
                client rattache:
                  summary: Client déjà utilisateur ITICK — rattachement direct
                  value:
                    receiptId: '3f2b1c44-0e6a-4b8d-9f31-8c2a77d51e90'
                    invoiceNumber: '73282932000074-00001'
                    matched: true
                    environment: sandbox
                    totals:
                      totalHt: 3
                      totalVat: 0.3
                      totalTtc: 3.3
                      currency: EUR
                client non rattache (mecanisme d'acquisition):
                  summary: >-
                    Client inconnu : ITICK renvoie le QR a presenter. C'est TOUT
                    le mecanisme d'acquisition — ne l'ignorez pas.
                  value:
                    receiptId: '9c1d7e02-5a44-4d19-b3f7-1e6c2a90bb34'
                    invoiceNumber: '73282932000074-00002'
                    matched: false
                    environment: sandbox
                    totals:
                      totalHt: 3
                      totalVat: 0.3
                      totalTtc: 3.3
                      currency: EUR
                    qrCode: 'data:image/png;base64,iVBORw0KGgo…'
                    qrCodeUrl: 'https://itick.fr/r/9c1d7e02?t=a1b2c3…'
                    qrToken: 'a1b2c3d4e5f6…'
        '400':
          description: |
            Requête invalide. `error` ∈ `MODE_REQUIRED` (ni items ni documentPdf),
            `VALIDATION_ERROR` (payload malformé), `DOCUMENT_PDF_TOO_LARGE`,
            `UNSUPPORTED_DOCUMENT_FORMAT`, `INVALID_CHANNEL` (customerEmail ET
            customerPhone fournis — n'en donnez qu'un), `INVALID_USER_ROLE`
            (l'e-mail ou le téléphone du client correspond à un compte qui n'est
            pas un compte utilisateur).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngressError' }
        '401':
          description: Clé API absente ou invalide.
        '404':
          description: >-
            `MERCHANT_NOT_FOUND` — aucun commerçant ne correspond au SIRET
            fourni.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngressError' }
        '409':
          description: Doublon détecté (même `externalId` ou même document déjà poussé).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/IngressError'
                  - type: object
                    properties:
                      existingReceiptId: { type: string, format: uuid }
        '422':
          description: |
            Mode simple uniquement : document illisible par l'OCR. Si un `totalTtc`
            était fourni, un brouillon minimal est quand même créé (`receiptId` présent).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/IngressError'
                  - type: object
                    properties:
                      receiptId: { type: string, format: uuid, nullable: true }
        '429':
          description: Rate limit dépassé (voir headers `X-RateLimit-*` / `Retry-After`).

  /receipts/providers/batch:
    post:
      summary: Pousser un lot de reçus (jusqu'à 100)
      operationId: pushReceiptBatch
      description: |
        Ingestion en masse : jusqu'à 100 reçus par requête, chacun structuré OU
        simple (lot hétérogène accepté). Chaque item passe la même logique que la
        route unitaire (dédup, PAF, matching, webhooks). Réponse Multi-Status :
        `200` si tout est créé, `207` s'il y a au moins un échec — chaque item
        porte son propre statut.
      tags: [Ingestion]
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 256 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receipts]
              properties:
                receipts:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: '#/components/schemas/PushReceiptRequest'
      responses:
        '200':
          description: Tous les reçus ont été créés.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushBatchResult' }
        '207':
          description: Succès partiel — voir le statut par item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushBatchResult' }
        '400':
          description: >-
            Enveloppe invalide (`receipts` absent, vide ou > 100 items). Les erreurs
            PAR ITEM ne sont jamais en 400 — elles sont dans le 207, item par item
            (`status: error`, `error: VALIDATION_ERROR`, `details[]` avec le chemin
            du champ).
        '401':
          description: Clé API absente ou invalide.

  /partner/receipts:
    get:
      summary: Liste des receipts du partenaire
      operationId: listReceipts
      description: |
        Retourne la liste paginee des receipts pousses par ce partenaire.
        Seuls les receipts crees via l'API Ingress de ce provider sont visibles.
      tags: [Receipts]
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: perPage
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
        - name: createdFrom
          in: query
          schema:
            type: string
            format: date-time
        - name: createdTo
          in: query
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Liste paginee des receipts
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Receipt'
        '401':
          description: Cle API invalide
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Rate limit depasse

  /partner/receipts/{id}:
    get:
      summary: Detail d'un receipt certifie
      operationId: getReceipt
      description: |
        Retourne le detail complet d'un receipt avec toutes les donnees
        certifiees (EN16931, RFC3161).
      tags: [Receipts]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Detail du receipt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Receipt'
        '404':
          description: Receipt non trouve ou non accessible

  /partner/receipts/{id}/certificate:
    get:
      summary: Certificat RFC3161 d'un receipt
      operationId: getReceiptCertificate
      description: |
        Retourne le token d'horodatage certifie RFC3161 (preuve temporelle).
        Si le receipt n'a pas encore ete horodate, `timestamped` sera `false`.
      tags: [Certificates]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Certificat RFC3161
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Certificate'
        '404':
          description: Receipt non trouve

  /partner/receipts/{id}/audit-trail:
    get:
      summary: Piste d'audit d'un receipt (PAF)
      operationId: getReceiptAuditTrail
      description: |
        Retourne l'historique chronologique complet de toutes les actions
        effectuees sur ce receipt (creation, modification, acces, etc.).
        Chaque entree est chainee par hash SHA-256 (Piste d'Audit Fiable).
      tags: [Audit]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Piste d'audit
          content:
            application/json:
              schema:
                type: object
                properties:
                  receiptId:
                    type: string
                  entries:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuditEntry'
        '404':
          description: Receipt non trouve

  /partner/receipts/{id}/qr:
    post:
      summary: Générer des QR codes pour un receipt
      operationId: createReceiptQr
      description: |
        Génère un ou plusieurs QR codes pour un receipt. Chaque QR code contient
        un token unique, valide 30 jours. Maximum 12 tokens par receipt.

        ⚠️ Le token est MULTI-USAGE pendant ces 30 jours : le faire réclamer une
        première fois ne l'invalide pas. Plusieurs personnes peuvent scanner le
        même QR pour ajouter le reçu à leur coffre, et seule l'expiration y met
        fin (`QrToken.isValid` ne regarde que la date). Ne le traitez donc pas
        comme un jeton à usage unique.

        **Deux formats disponibles :**
        - `standard` (défaut) : Data URL PNG (`data:image/png;base64,...`), idéal pour affichage web
        - `thermal` : Base64 PNG brut (sans préfixe data URL), optimisé pour imprimantes thermiques POS (58mm/80mm)

        En mode `thermal`, la réponse inclut `printText` et `printUrl` pour l'intégration ESC/POS.
        Le texte et l'URL ne sont PAS intégrés dans l'image QR — le SDK POS les affiche séparément.
      tags: [QR Codes]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                count:
                  type: integer
                  minimum: 1
                  maximum: 12
                  default: 1
                  description: "Nombre de QR codes à générer"
                format:
                  type: string
                  enum: [standard, thermal]
                  default: standard
                  description: "Format de sortie (standard = data URL, thermal = base64 brut)"
                width:
                  type: integer
                  minimum: 50
                  maximum: 1000
                  description: "Largeur en pixels (défaut : 300 standard, 200 thermal)"
      responses:
        '201':
          description: QR codes générés
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/QrCodeResult'
        '400':
          description: Paramètres invalides (format inconnu, width hors limites)
        '404':
          description: Receipt non trouvé ou non accessible
        '409':
          description: >-
            `QR_MAX_TOKENS_REACHED` : limite de 12 tokens VALIDES atteinte (les
            tokens expirés ou révoqués ne comptent plus, depuis le 03/09/2026) ;
            `RECEIPT_NOT_CLAIMABLE` : le reçu appartient à une organisation et
            n'a plus de propriétaire (retiré par un membre), ou il a été
            supprimé — il n'est plus réclamable, aucun QR ne peut être émis
            pour lui.

  /partner/billing-identity:
    get:
      summary: Identite de facturation d'un client
      operationId: getBillingIdentity
      security:
        - ApiKeyAuth: []
      description: |
        Resout un SIREN (9 chiffres) ou un SIRET (14 chiffres) en identite de
        facturation publique, pour afficher le nom du client au moment de
        l'encaissement et pre-remplir la facture.

        Toutes les donnees viennent de la base SIRENE, qui est publique. Cette
        route ne dit JAMAIS si l'entreprise a un compte iTick.

        `identityStatus` a cinq valeurs, et chacune appelle une conduite
        differente en caisse :
          * `known`              : afficher le nom, pre-remplir la facture ;
          * `unknown`            : l'entreprise n'existe pas, demander au client ;
          * `ceased`             : elle est RADIEE, ne pas facturer sans verifier ;
          * `restricted`         : l'INSEE masque ses donnees a la demande du
                                   titulaire, c'est au client de les donner ;
          * `lookup_unavailable` : notre service n'a pas pu repondre. Ce n'est
                                   PAS `unknown` : dire qu'une entreprise
                                   n'existe pas quand on n'a pas su demander
                                   serait un mensonge.

        `company` vaut `null` des que `identityStatus` n'est pas `known`.

        ATTENTION sur le numero de TVA : il vaut `null` quand l'entreprise n'en
        a pas (franchise en base, association) — environ quatre entrepreneurs
        individuels sur cinq. Il n'est JAMAIS calcule a partir du SIREN :
        inscrire un numero fabrique sur une facture est une erreur de
        conformite. `vatNumberSource` dit d'ou il vient.

        `addressKind` precise si l'adresse est celle du SIEGE ou de
        l'ETABLISSEMENT demande. Un SIRET d'etablissement secondaire renvoie
        l'adresse de cet etablissement.

        `address.country` vaut `FR` ou `null` — jamais devine. La norme EN16931
        (BR-57) exige un code pays sur une adresse de livraison ; quand
        l'etablissement est a l'etranger, l'INSEE le code sur sa propre
        nomenclature et le libelle est rendu dans `countryLabel`.
      tags: [Merchants]
      parameters:
        - name: siren
          in: query
          required: true
          schema:
            type: string
            pattern: '^[0-9]{9}([0-9]{5})?$'
          description: SIREN (9 chiffres) ou SIRET (14 chiffres). Les separateurs sont ignores.
          example: '831234567'
      responses:
        '200':
          description: Identite resolue, ou statut expliquant pourquoi elle ne l'est pas
          content:
            application/json:
              schema:
                type: object
                required: [identifier, identityStatus, company]
                properties:
                  identifier:
                    type: object
                    properties:
                      siren:
                        type: string
                        example: '831234567'
                      siret:
                        type: string
                        nullable: true
                        example: '83123456700018'
                  identityStatus:
                    type: string
                    enum: [known, unknown, ceased, restricted, lookup_unavailable]
                  company:
                    type: object
                    nullable: true
                    properties:
                      legalName:
                        type: string
                        nullable: true
                        example: LAMBERT ARCHITECTURE
                      legalForm:
                        type: string
                        nullable: true
                      nafCode:
                        type: string
                        nullable: true
                      vatNumber:
                        type: string
                        nullable: true
                        example: FR43831234567
                      vatNumberSource:
                        type: string
                        enum: [dgfip, none]
                      addressKind:
                        type: string
                        enum: [registered_office, establishment]
                      address:
                        type: object
                        properties:
                          street:
                            type: string
                            nullable: true
                          postalCode:
                            type: string
                            nullable: true
                          city:
                            type: string
                            nullable: true
                          country:
                            type: string
                            nullable: true
                            enum: ['FR', null]
                          countryLabel:
                            type: string
                            nullable: true
        '401':
          description: Cle API absente, invalide ou revoquee
        '403':
          description: |
            La cle ne porte pas le scope `billing-identity:read`. Les cles
            creees ou actives depuis le 2026-09-03 l'ont par defaut ; une cle
            aux scopes restreints doit se le voir accorder explicitement.
        '400':
          description: Le parametre `siren` n'est ni un SIREN ni un SIRET
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: INVALID_IDENTIFIER
                  message:
                    type: string

  /partner/merchants:
    get:
      summary: Merchants du partenaire
      operationId: listMerchants
      description: |
        Liste les merchants pour lesquels ce partenaire a pousse des receipts.
        Inclut le nombre de receipts et la date du dernier.
      tags: [Merchants]
      responses:
        '200':
          description: Liste des merchants
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Merchant'

  /partner/webhooks:
    get:
      summary: Liste des webhooks configures
      operationId: listWebhooks
      tags: [Webhooks]
      responses:
        '200':
          description: Webhooks du partenaire
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
    post:
      summary: Creer un webhook
      operationId: createWebhook
      description: |
        Cree un nouveau webhook endpoint. Un secret HMAC-SHA256 est genere
        automatiquement pour la verification de signature.

        **Verification de signature (format facon Stripe, anti-rejeu)** :
        Le header `X-iTick-Signature` vaut `t=<unix>,v1=<hmac>` ou
        `hmac = HMAC-SHA256("<t>.<rawBody>", secret)`.
        1. Extraire `t` et `v1` du header.
        2. Recalculer `HMAC-SHA256(t + "." + rawBody, secret)` et comparer a `v1`
           (comparaison a temps constant).
        3. Rejeter si `now - t` depasse votre tolerance (ex. 5 min) — protege du rejeu.

        **En-tetes envoyes a CHAQUE livraison** (y compris le ping de test) :

        | En-tete | Contenu |
        |---|---|
        | `X-iTick-Signature` | `t=<unix>,v1=<hmac>` — voir ci-dessus |
        | `X-iTick-Event` | Nom de l'evenement (ex. `receipt.created`) |
        | `X-iTick-EventId` | Identifiant de l'evenement — STABLE a travers les rejeux : c'est votre cle d'idempotence |
        | `X-iTick-Delivery` | Identifiant unique de CETTE tentative de livraison (change a chaque rejeu) |
        | `X-iTick-Retry` | Numero de tentative (1 = premier envoi) |
        | `X-iTick-Environment` | `sandbox` ou `production`. ⚠️ ABSENT sur les endpoints anterieurs a WH-06 : traitez son absence comme `production` |

        **Environnements** : l'endpoint herite de l'environnement du canal de
        creation (cle API sandbox → endpoint sandbox, cle production → endpoint
        production ; portail → statut d'onboarding du compte). Declarez UN
        endpoint PAR environnement pour continuer a tester en sandbox une fois
        en production. Chaque livraison porte le header `X-iTick-Environment`
        et le champ `environment` du payload.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  example: https://example.com/webhooks/itick
                events:
                  type: array
                  description: 'Pour un POS, abonnez-vous au moins à `receipt.assigned` : c''est lui qui confirme que le ticket a atteint le coffre d''un vrai client.'
                  items:
                    type: string
                    enum: [receipt.created, receipt.updated, receipt.deleted, receipt.assigned]
      responses:
        '201':
          description: Webhook cree (le secret est retourne une seule fois)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: "Secret HMAC-SHA256 (conservez-le, il ne sera plus affiche)"
        '400':
          description: Parametres invalides

  /partner/webhooks/{id}:
    put:
      summary: Modifier un webhook
      operationId: updateWebhook
      description: |
        Apres 5 echecs de livraison consecutifs, l'endpoint est desactive
        automatiquement (active=false) et les evenements partent en dead_letter.
        `{"active": true}` sur un endpoint desactive le REACTIVE et remet son
        compteur d'echecs a zero. Sequence de reprise apres un incident :
        1. `PUT /partner/webhooks/{id}` avec `{"active": true}` — retablit le
           flux temps reel ;
        2. rejouez chaque evenement `dead_letter` via
           `POST /partner/webhooks/events/{eventId}/replay` — sinon les
           evenements de la fenetre de panne ne seront jamais relivres.
        (Le replay d'un evenement dead_letter reactive de lui-meme l'endpoint
        si necessaire.)
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  items:
                    type: string
                    enum: [receipt.created, receipt.updated, receipt.deleted, receipt.assigned]
                active:
                  type: boolean
      responses:
        '200':
          description: Webhook mis a jour
        '404':
          description: Webhook non trouve
    delete:
      summary: Supprimer un webhook
      operationId: deleteWebhook
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Webhook supprime
        '404':
          description: Webhook non trouve

  /partner/webhooks/events:
    get:
      operationId: listWebhookEvents
      summary: Journal des livraisons webhook
      description: >-
        Historique pagine des evenements webhook du partenaire (statut,
        tentatives, statut HTTP et cause du dernier echec, payload).
      tags: [Webhooks]
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: perPage
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Journal pagine
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEventLog'
                  total:
                    type: integer

  /partner/webhooks/events/{eventId}:
    get:
      operationId: getWebhookEvent
      summary: Detail d'un evenement webhook
      tags: [Webhooks]
      parameters:
        - name: eventId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Evenement
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEventLog'
        '404':
          description: Evenement non trouve

  /partner/webhooks/events/{eventId}/replay:
    post:
      operationId: replayWebhookEvent
      summary: Rejouer un evenement failed ou dead_letter
      description: |
        Relivre l'evenement a son endpoint. C'est L'ACTION DE REPRISE apres un
        incident : si l'endpoint a ete desactive automatiquement (5 echecs),
        le replay le reactive et remet son compteur d'echecs a zero avant de
        relivrer. Environ 3 replays maximum par evenement (garde anti-boucle).
      tags: [Webhooks]
      parameters:
        - name: eventId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '202':
          description: Replay lance
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: replaying
        '404':
          description: Evenement non trouve
        '409':
          description: Deja livre, ou livraison en cours
        '410':
          description: Endpoint supprime (recreez un webhook)
        '429':
          description: Nombre maximum de replays atteint

  /partner/health:
    get:
      operationId: partnerHealthCheck
      summary: Ping de sante — valide l'etape « Ping de sante » de la checklist
      description: |
        Sonde de connectivite authentifiee. C'est le PREMIER appel du parcours
        d'integration : il valide l'etape `healthCheck` de la checklist
        d'onboarding.

        ⚠️ L'etape n'est cochee QU'EN SANDBOX. Appelez cette route avec une cle
        `itick_sandbox_…`, ou depuis le portail avec un JWT portant l'en-tete
        `X-iTick-Environment: sandbox`. Avec une cle de production, la route
        repond bien `200` mais ne coche rien.
      tags: [Onboarding]
      responses:
        '200':
          description: Service joignable ; en sandbox, l'etape « Ping de sante » est cochee.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  providerId:
                    type: string
                  environment:
                    type: string
                    enum: [sandbox, production]
                  timestamp:
                    type: string
                    format: date-time
        '401':
          description: Cle API absente ou invalide

  /partner/onboarding:
    get:
      operationId: getOnboardingChecklist
      summary: Checklist d'onboarding et score de preparation
      description: |
        Etat detaille des six etapes a valider avant le passage en production,
        et le `readinessScore` (0 a 100 %) qui en decoule. C'est la route a
        interroger pour savoir OU vous en etes.

        Les six etapes et l'appel EXACT qui valide chacune :

        | Etape | Appel qui la valide |
        |---|---|
        | `healthCheck` | `GET /partner/health` |
        | `merchantCreated` | `POST /partner/merchants` |
        | `receiptSent` | `POST /receipts/providers` |
        | `webhookConfigured` | `POST /partner/webhooks` |
        | `webhookDelivered` | `POST /partner/webhooks/{id}/test` (reponse 2xx exigee) |
        | `qrTested` | `POST /partner/onboarding/verify-qr` |

        ⚠️ Deux pieges verifies en conditions reelles :
        - les six etapes ne se cochent QU'EN SANDBOX (cle `itick_sandbox_…`, ou
          JWT + en-tete `X-iTick-Environment: sandbox`) ;
        - `qrTested` n'est validee QUE par `POST /partner/onboarding/verify-qr`.
          Generer un QR (`POST /partner/receipts/{id}/qr`, ou `generateQr: true`
          a l'ingestion) et visiter l'URL de reclamation ne cochent RIEN.
      tags: [Onboarding]
      responses:
        '200':
          description: Checklist et score courants
          content:
            application/json:
              schema:
                type: object
                properties:
                  providerId:
                    type: string
                  status:
                    type: string
                    enum: [sandbox_active, ready, prod_requested, active_prod, rejected]
                  readinessScore:
                    type: integer
                    description: Pourcentage de completion (6 etapes ; 5/6 = 83).
                    example: 83
                  checklist:
                    type: object
                    properties:
                      healthCheck:
                        $ref: '#/components/schemas/ChecklistStep'
                      merchantCreated:
                        $ref: '#/components/schemas/ChecklistStep'
                      receiptSent:
                        $ref: '#/components/schemas/ChecklistStep'
                      webhookConfigured:
                        $ref: '#/components/schemas/ChecklistStep'
                      webhookDelivered:
                        $ref: '#/components/schemas/ChecklistStep'
                      qrTested:
                        $ref: '#/components/schemas/ChecklistStep'
                  prodRequestedAt:
                    type: string
                    format: date-time
                    nullable: true
                  prodApprovedAt:
                    type: string
                    format: date-time
                    nullable: true
                  prodRejectedAt:
                    type: string
                    format: date-time
                    nullable: true
                  rejectionReason:
                    type: string
                    nullable: true
                  badgeGrantedAt:
                    type: string
                    format: date-time
                    nullable: true
        '404':
          description: Aucun dossier d'onboarding pour ce partenaire

  /partner/onboarding/verify-qr:
    post:
      operationId: verifyOnboardingQr
      summary: Verifier un QR — SEUL validateur de l'etape « QR teste »
      description: |
        Vous decodez un QR code emis par ITICK et nous renvoyez sa charge utile
        pour verification. C'est le SEUL appel qui valide l'etape `qrTested` de
        la checklist.

        ⚠️ Ni `POST /partner/receipts/{id}/qr`, ni `generateQr: true` a
        l'ingestion, ni la visite de l'URL de reclamation ne cochent cette
        etape : ils produisent le QR, ils ne prouvent pas que vous savez le
        LIRE. Cette route l'exige.

        `qrPayload` est une CHAINE contenant du JSON, porteuse d'au moins
        `receiptId`. Le reçu doit appartenir a votre compte ET au meme
        environnement que votre cle.

        ⚠️ Comme les cinq autres, l'etape n'est cochee QU'EN SANDBOX : en
        production la reponse est `200 verified: true`, mais rien n'est coche.
      tags: [Onboarding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qrPayload]
              properties:
                qrPayload:
                  type: string
                  description: Charge utile du QR, sous forme de chaine JSON contenant `receiptId`.
            examples:
              payload d'un recu sandbox:
                value:
                  qrPayload: '{"receiptId":"3f2b1c44-0e6a-4b8d-9f31-8c2a77d51e90"}'
      responses:
        '200':
          description: QR verifie ; en sandbox, l'etape « QR teste » est cochee.
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified:
                    type: boolean
                  receiptId:
                    type: string
                  readinessScore:
                    type: integer
                    example: 100
                  status:
                    type: string
                    nullable: true
        '400':
          description: >-
            `qrPayload` absent, non parsable en JSON, ou sans `receiptId`.
        '404':
          description: Recu introuvable, ou n'appartenant pas a ce partenaire (ou a un autre environnement).

  /partner/onboarding/request-prod:
    post:
      operationId: requestProductionAccess
      summary: Passage en production (self-serve) et remise de la cle production
      description: |
        Ouvre l'acces production SANS validation manuelle des lors que TROIS
        conditions sont reunies :
        1. checklist a 100 % (les six etapes cochees) ;
        2. au moins un reçu reellement pousse en SANDBOX via l'API ;
        3. au moins une livraison de webhook reussie (livraison reelle, ou ping
           de test repondu en 2xx).

        En cas de succes, la cle de production est renvoyee UNE SEULE FOIS
        (champs `key` et `rawKey`, valeurs identiques) : stockez-la
        immediatement, elle n'est plus jamais affichee.

        En cas de refus, le `400` porte `details[]` : chaque condition
        manquante y est nommee EN FRANÇAIS avec l'appel exact qui la valide.
      tags: [Onboarding]
      responses:
        '200':
          description: Acces production accorde ; cle renvoyee une seule fois.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  status:
                    type: string
                    example: active_prod
                  apiKey:
                    type: object
                    properties:
                      id:
                        type: string
                      key:
                        type: string
                        description: Cle de production en clair — affichee UNE seule fois.
                      rawKey:
                        type: string
                        description: Meme valeur que `key` (compatibilite ascendante).
                      keyPrefix:
                        type: string
                      scopes:
                        type: array
                        items:
                          type: string
                      environment:
                        type: string
                        example: production
        '400':
          description: >-
            Conditions non reunies. `details[]` nomme chaque etape manquante et
            l'appel qui la valide.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: Bad Request
                  message:
                    type: string
                  details:
                    type: array
                    items:
                      type: string
              examples:
                checklist incomplete (83 %):
                  value:
                    error: Bad Request
                    message: Le score de préparation doit atteindre 100 % (actuellement 83 %).
                    details:
                      - >-
                        Étape « QR testé » (qrTested) non validée : POST
                        /partner/onboarding/verify-qr avec le corps
                        {"qrPayload":"{\"receiptId\":\"<id d'un reçu sandbox>\"}"}.
                      - >-
                        Rappel : les six étapes ne se valident qu'en sandbox —
                        utilisez une clé « itick_sandbox_… », ou un JWT portant
                        l'en-tête « X-iTick-Environment: sandbox ».
        '404':
          description: Aucun dossier d'onboarding pour ce partenaire
        '409':
          description: Passage en production deja demande ou deja accorde

  /partner/webhooks/{id}/test:
    post:
      operationId: testWebhookEndpoint
      summary: Ping de test — valide l'etape « Webhook livre »
      description: |
        Envoie un evenement `test.ping` a votre endpoint. C'est l'appel exige
        par l'etape `webhookDelivered` de la checklist.

        ⚠️ L'etape n'est cochee QUE si votre endpoint repond reellement 2xx :
        la reponse `200` ci-dessous decrit le RESULTAT du ping, pas son succes.
        Lisez `success` — un ping en echec renvoie lui aussi `200`, avec
        `success: false`.
      tags: [Webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Resultat du ping (succes OU echec — lisez `success`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpointId:
                    type: string
                  url:
                    type: string
                  success:
                    type: boolean
                    description: true si l'endpoint a repondu 2xx — c'est ce qui coche l'etape.
                  httpStatus:
                    type: integer
                    nullable: true
                  durationMs:
                    type: integer
                    nullable: true
                  error:
                    type: string
                    nullable: true
        '404':
          description: Endpoint introuvable ou n'appartenant pas a ce partenaire

tags:
  - name: Onboarding
    description: Parcours d'integration — checklist, verification QR et passage en production
  - name: Ingestion
    description: Envoi des tickets/reçus vers ITICK (mode structuré ou document)
  - name: Receipts
    description: Acces aux receipts certifies
  - name: Certificates
    description: Certificats RFC3161 (horodatage)
  - name: Audit
    description: Piste d'Audit Fiable (PAF)
  - name: Merchants
    description: Merchants
  - name: QR Codes
    description: Génération de QR codes pour receipts (standard et thermal POS)
  - name: Webhooks
    description: Notifications en temps reel
