{
  "openapi": "3.1.0",
  "info": {
    "title": "API Passcard",
    "version": "1.0.0",
    "summary": "Connectez votre caisse ou votre logiciel de vente à la fidélité Passcard.",
    "description": "L'API Passcard permet à un logiciel de caisse, une solution de commande ou un script maison de créditer directement le programme de fidélité d'un commerçant (tampon ou points), et de consulter ses cartes de fidélité.\n\n## Obtenir sa clé\n\nLa clé API se génère depuis le tableau de bord Passcard : **Réglages → Intégration caisse (API)**. Elle est affichée en clair une seule fois au moment de la génération — notez-la immédiatement, ou régénérez-en une nouvelle si vous l'avez perdue (l'ancienne est alors révoquée).\n\nCette fonctionnalité est réservée aux plans **Business** et **Franchise** (un essai Business en cours y donne également accès).\n\n## Authentification\n\nChaque requête porte un header `Authorization: Bearer <votre clé>`. Une clé absente, invalide, ou appartenant à un compte dont le plan est inférieur à Business est refusée avec un `401 Unauthorized`.\n\n## Identifier un client : téléphone ou QR code\n\nVous pouvez identifier le client de deux façons, dans le corps de la requête `POST /api/v1/stamp` :\n\n- **`phone`** : le numéro de téléphone du client, dans n'importe quel format usuel (il est normalisé automatiquement au format international).\n- **`qr`** : le contenu brut scanné sur le pass Wallet du client. Le QR code d'un pass Passcard encode une chaîne au format `passcard:{uuid}` (par exemple `passcard:3fa85f64-5717-4562-b3fc-2c963f66afa6`). Si votre douchette est mal configurée côté clavier (AZERTY/QWERTY), l'identifiant reste reconnu tant que les caractères hexadécimaux de l'UUID sont présents.\n\nAu moins l'un des deux (`phone` ou `qr`) est requis. Si les deux sont fournis, `qr` est prioritaire : dès qu'un UUID en est extrait, c'est lui qui identifie le client, et un `qr` qui ne correspond à aucun client de ce commerçant renvoie `404` sans repli sur le téléphone (une référence explicite qui échoue ne doit jamais créditer quelqu'un d'autre). En revanche, un `qr` d'où AUCUN UUID n'est extractible (champ vide de sens, scan illisible) est ignoré, et le `phone` est alors utilisé.\n\n## Mode tampons ou mode points\n\nLa carte de fidélité du commerçant fonctionne soit en **mode tampons** (10 tampons = 1 récompense, par exemple), soit en **mode points** (X points par euro dépensé). Vous n'avez pas besoin de connaître le mode à l'avance : l'API le détermine automatiquement depuis la carte du client.\n\n- En **mode tampons**, `amount` est ignoré (un appel = un tampon).\n- En **mode points**, `amount` (montant TTC du ticket, **en euros**, pas en centimes) est **requis** : sans lui, l'appel échoue avec `400 Bad Request` (code `AMOUNT_REQUIRED`).\n\nLa réponse `200 OK` reflète le mode réellement utilisé (`mode: \"stamps\"` ou `mode: \"points\"`), avec des champs différents selon le cas — voir les exemples de la route `POST /api/v1/stamp`.\n\n## Idempotence et `ticket_id`\n\nFournissez systématiquement `ticket_id` (l'identifiant du ticket ou de la vente dans votre système). Il sert de clé d'idempotence côté Passcard : si votre appel échoue en réseau (timeout, 5xx transitoire) et que vous le réessayez avec le **même** `ticket_id`, Passcard ne créditera le client qu'une seule fois. Le second appel renvoie alors un `200 OK` identique au premier, avec `\"replayed\": true`.\n\nSans `ticket_id`, chaque appel HTTP est traité comme une intention distincte et **non déduplicable** : un retry réseau de votre part peut créditer le client deux fois. Tout appelant capable de réessayer un appel doit fournir un `ticket_id` stable.\n\nUn appel avec un `ticket_id` déjà en cours de traitement (rejeu quasi simultané, fenêtre de quelques centaines de millisecondes) renvoie `409 Conflict` (code `REPLAY_IN_PROGRESS`) — réessayez après un court délai.\n\n## Anti-double-tampon (cooldown)\n\nEn mode tampons, deux tampons pour le même client à moins de 10 secondes d'intervalle sont bloqués côté serveur (`409 Conflict`, code `COOLDOWN`) — garde technique contre un double scan accidentel ou un double clic. Ce n'est pas une erreur d'intégration : proposez un message clair au caissier (« déjà tamponné il y a quelques secondes ») plutôt que de réessayer automatiquement.\n\n## Limite de débit (rate limiting)\n\n**60 requêtes par minute** par clé API, tous endpoints confondus. Au-delà, l'API renvoie `429 Too Many Requests`. Espacez vos appels ou mettez en file d'attente les tickets en cas de forte affluence.\n\n---\n\n### Éditeurs de caisse\n\nAppelez `POST /api/v1/stamp` à la clôture du ticket, avec le téléphone du client saisi en caisse ou le scan de son pass Wallet. Fournissez `ticket_id` = l'identifiant de votre ticket, pour que vos retries réseau ne créditent jamais deux fois le même client.",
    "contact": {
      "name": "Support Passcard",
      "url": "https://passcard.fr/developpeurs",
      "email": "communication@passcard.fr"
    }
  },
  "servers": [
    {
      "url": "https://passcard.fr",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Fidélité",
      "description": "Créditer un tampon ou des points depuis une caisse ou un script."
    },
    {
      "name": "Cartes",
      "description": "Consulter les cartes de fidélité du compte."
    }
  ],
  "paths": {
    "/api/v1/stamp": {
      "post": {
        "tags": ["Fidélité"],
        "summary": "Créditer un client (tampon ou points)",
        "description": "Identifie un client (par téléphone ou par QR code de son pass Wallet) et le crédite : un tampon si sa carte est en mode tampons, ou des points (calculés depuis `amount`) si sa carte est en mode points. À appeler à la clôture de chaque ticket concerné.\n\nRequiert le plan Business ou Franchise.",
        "operationId": "postStamp",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/StampRequest" },
              "examples": {
                "parTelephone": {
                  "summary": "Identification par téléphone, carte en mode tampons",
                  "value": {
                    "phone": "0612345678",
                    "ticket_id": "ticket-2026-08-20-00042"
                  }
                },
                "parQr": {
                  "summary": "Identification par scan du pass Wallet, carte en mode points",
                  "value": {
                    "qr": "passcard:3fa85f64-5717-4562-b3fc-2c963f66afa6",
                    "amount": 24.9,
                    "ticket_id": "ticket-2026-08-20-00043"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Client crédité (ou rejeu d'un appel identique déjà traité, `replayed: true`).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/StampSuccessStamps" },
                    { "$ref": "#/components/schemas/StampSuccessPoints" }
                  ]
                },
                "examples": {
                  "modeTampons": {
                    "summary": "Carte en mode tampons — tampon posé",
                    "value": {
                      "mode": "stamps",
                      "replayed": false,
                      "result": {
                        "stamp_id": "8e6f2b1a-4d3c-4a2b-9f1e-1234567890ab",
                        "current_stamps": 4,
                        "stamps_remaining": 6,
                        "reward_unlocked": false
                      }
                    }
                  },
                  "modeTamponsRecompense": {
                    "summary": "Carte en mode tampons — dernier tampon, récompense débloquée",
                    "value": {
                      "mode": "stamps",
                      "replayed": false,
                      "result": {
                        "stamp_id": "8e6f2b1a-4d3c-4a2b-9f1e-1234567890ac",
                        "current_stamps": 10,
                        "stamps_remaining": 0,
                        "reward_unlocked": true,
                        "reward_id": "5b1e2c3d-4f5a-4b6c-8d7e-0987654321fe",
                        "reward_text": "1 café offert"
                      }
                    }
                  },
                  "modePoints": {
                    "summary": "Carte en mode points",
                    "value": {
                      "mode": "points",
                      "replayed": false,
                      "result": {
                        "points_added": 25,
                        "new_balance": 140,
                        "reward_unlocked": false
                      }
                    }
                  },
                  "rejeu": {
                    "summary": "Rejeu — même ticket_id déjà traité",
                    "value": {
                      "mode": "stamps",
                      "replayed": true,
                      "result": {
                        "stamp_id": "8e6f2b1a-4d3c-4a2b-9f1e-1234567890ab",
                        "current_stamps": 4,
                        "stamps_remaining": 6,
                        "reward_unlocked": false
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Payload invalide : ni `phone` ni `qr` fourni, `amount` manquant pour une carte en mode points, ou champ hors des contraintes (longueur, format JSON).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "champsManquants": {
                    "summary": "Ni phone ni qr fourni",
                    "value": { "error": "phone ou qr requis" }
                  },
                  "montantRequis": {
                    "summary": "Carte en mode points sans amount",
                    "value": { "error": "Un montant est requis pour créditer des points" }
                  },
                  "jsonInvalide": {
                    "summary": "Corps de requête non-JSON",
                    "value": { "error": "JSON invalide" }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Clé API absente, invalide, ou compte dont le plan est inférieur à Business (Business, Franchise, ou essai Business en cours sont requis).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "nonAutorise": { "value": { "error": "Unauthorized" } }
                }
              }
            }
          },
          "404": {
            "description": "Aucun client trouvé pour le téléphone ou le QR fourni, sur ce compte commerçant. La réponse inclut `signup_url`, un lien public vers lequel orienter le client pour qu'il crée sa carte.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CustomerNotFoundResponse" },
                "examples": {
                  "clientInconnu": {
                    "value": {
                      "error": "Client introuvable",
                      "signup_url": "https://passcard.fr/c/ma-boutique"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Conflit : garde technique anti-double-tampon (10 s), récompense en attente de validation, mode de carte incompatible, ou rejeu concurrent d'un même `ticket_id` en cours de traitement.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "cooldown": {
                    "summary": "Tampon déjà donné récemment",
                    "value": { "error": "Tampon déjà donné, réessayez dans 4 min" }
                  },
                  "rejeuEnCours": {
                    "summary": "Même ticket_id en cours de traitement ailleurs",
                    "value": { "error": "Cette intention est déjà en cours de traitement, réessayez" }
                  },
                  "recompenseEnAttente": {
                    "summary": "Récompense débloquée non encore validée",
                    "value": { "error": "Récompense en attente : validez-la d'abord" }
                  }
                }
              }
            }
          },
          "410": {
            "description": "Carte de fidélité désactivée par le commerçant. Le client existe, mais sa carte n'accepte plus de tampon ni de point. Ne pas réessayer : l'état ne changera pas sans une action du commerçant.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "carteInactive": {
                    "summary": "Carte désactivée",
                    "value": { "error": "Cette carte n'est plus active" }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Trop de requêtes pour cette clé API (limite : 60/minute).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "tropDeRequetes": {
                    "value": { "error": "Trop de tentatives. Veuillez réessayer dans quelques minutes." }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Erreur interne. Contrairement aux 4xx, ce statut se rejoue : réessayez avec le MÊME `ticket_id`, l'appel sera dédupliqué et le client ne sera crédité qu'une fois.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "erreurInterne": { "value": { "error": "Erreur interne" } }
                }
              }
            }
          },
          "503": {
            "description": "Vérification de la clé API momentanément indisponible. Même consigne qu'un 500 : réessayez avec le même `ticket_id`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "indisponible": { "value": { "error": "Service unavailable" } }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/cards": {
      "get": {
        "tags": ["Cartes"],
        "summary": "Lister les cartes de fidélité du compte",
        "description": "Renvoie toutes les cartes de fidélité (actives ou non) appartenant au compte commerçant authentifié.\n\nRequiert le plan Business ou Franchise.",
        "operationId": "getCards",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Liste des cartes.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CardsResponse" },
                "examples": {
                  "deuxCartes": {
                    "value": {
                      "cards": [
                        {
                          "id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081",
                          "merchant_id": "9f8e7d6c-5b4a-4392-8271-605948372615",
                          "name": "Carte fidélité café",
                          "mode": "stamps",
                          "stamp_count": 10,
                          "points_per_euro": null,
                          "reward_text": "1 café offert",
                          "bg_color": "#1a1a2e",
                          "text_color": "#ffffff",
                          "accent_color": "#e94560",
                          "is_active": true,
                          "is_primary": true,
                          "created_at": "2026-03-12T09:15:00.000Z"
                        },
                        {
                          "id": "2c3d4e5f-6071-4829-ab3c-4d5e6f708192",
                          "merchant_id": "9f8e7d6c-5b4a-4392-8271-605948372615",
                          "name": "Carte points restaurant",
                          "mode": "points",
                          "stamp_count": null,
                          "points_per_euro": 1,
                          "reward_text": "50 € offerts à 500 points",
                          "bg_color": "#0f3d3e",
                          "text_color": "#ffffff",
                          "accent_color": "#f4a261",
                          "is_active": true,
                          "is_primary": false,
                          "created_at": "2026-05-02T14:40:00.000Z"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Clé API absente, invalide, ou plan insuffisant.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": { "nonAutorise": { "value": { "error": "Unauthorized" } } }
              }
            }
          },
          "429": {
            "description": "Trop de requêtes pour cette clé API (limite : 60/minute).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "tropDeRequetes": {
                    "value": { "error": "Trop de tentatives. Veuillez réessayer dans quelques minutes." }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Vérification de la clé API momentanément indisponible. Réessayez dans quelques instants.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "indisponible": { "value": { "error": "Service unavailable" } }
                }
              }
            }
          },
          "500": {
            "description": "Erreur interne.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": { "erreurInterne": { "value": { "error": "Internal error" } } }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Clé API générée depuis Réglages → Intégration caisse (API), plan Business ou Franchise requis."
      }
    },
    "schemas": {
      "StampRequest": {
        "type": "object",
        "description": "Au moins l'un de `phone` ou `qr` est requis. Si les deux sont fournis, `qr` prévaut.",
        "properties": {
          "phone": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "description": "Numéro de téléphone du client, n'importe quel format usuel (normalisé automatiquement)."
          },
          "qr": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Contenu brut scanné sur le pass Wallet du client, au format `passcard:{uuid}`."
          },
          "amount": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 10000,
            "description": "Montant TTC du ticket, en EUROS (pas en centimes). Requis uniquement si la carte du client est en mode points."
          },
          "ticket_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128,
            "description": "Identifiant du ticket/vente côté appelant. Recommandé : sert de clé d'idempotence pour dédupliquer les retries réseau."
          }
        }
      },
      "StampSuccessStamps": {
        "type": "object",
        "properties": {
          "mode": { "type": "string", "enum": ["stamps"] },
          "replayed": {
            "type": "boolean",
            "description": "true si cette réponse provient d'un rejeu (même ticket_id déjà traité), pas d'un nouveau crédit."
          },
          "result": {
            "type": "object",
            "properties": {
              "stamp_id": { "type": "string", "format": "uuid" },
              "current_stamps": { "type": "integer" },
              "stamps_remaining": { "type": "integer" },
              "reward_unlocked": { "type": "boolean" },
              "reward_id": { "type": "string", "format": "uuid" },
              "reward_text": { "type": "string" }
            },
            "required": ["stamp_id", "current_stamps", "stamps_remaining", "reward_unlocked"]
          }
        },
        "required": ["mode", "replayed", "result"]
      },
      "StampSuccessPoints": {
        "type": "object",
        "properties": {
          "mode": { "type": "string", "enum": ["points"] },
          "replayed": {
            "type": "boolean",
            "description": "true si cette réponse provient d'un rejeu (même ticket_id déjà traité), pas d'un nouveau crédit."
          },
          "result": {
            "type": "object",
            "properties": {
              "points_added": { "type": "integer" },
              "new_balance": { "type": "integer" },
              "reward_unlocked": { "type": "boolean" }
            },
            "required": ["points_added", "new_balance", "reward_unlocked"]
          }
        },
        "required": ["mode", "replayed", "result"]
      },
      "Card": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "merchant_id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "mode": { "type": "string", "enum": ["stamps", "points"] },
          "stamp_count": { "type": ["integer", "null"] },
          "points_per_euro": { "type": ["number", "null"] },
          "reward_text": { "type": ["string", "null"] },
          "bg_color": { "type": "string" },
          "text_color": { "type": "string" },
          "accent_color": { "type": "string" },
          "is_active": { "type": "boolean" },
          "is_primary": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "CardsResponse": {
        "type": "object",
        "properties": {
          "cards": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Card" }
          }
        },
        "required": ["cards"]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": { "type": "string" }
        },
        "required": ["error"]
      },
      "CustomerNotFoundResponse": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "signup_url": {
            "type": "string",
            "format": "uri",
            "description": "Lien public vers lequel orienter le client pour qu'il crée sa carte de fidélité."
          }
        },
        "required": ["error"]
      }
    }
  }
}
