{
  "openapi": "3.1.0",
  "info": {
    "title": "tibida API",
    "version": "1.0.0",
    "description": "API publique tibida v1 — REST pour agir, webhooks pour réagir.\n\nExposée aux partenaires approuvés : boutiques, catalogue, commandes,\nréservations et fidélité. Les prix sont toujours calculés côté serveur.\nToutes les erreurs suivent l'enveloppe `{ \"error\": \"<code_snake>\", \"message\": \"<fr>\" }`\navec des codes d'erreur en français.",
    "contact": { "name": "tibida", "email": "hello@tibida.com" }
  },
  "servers": [
    { "url": "https://app.tibida.com", "description": "Production" }
  ],
  "security": [
    { "BearerAuth": [] },
    { "ApiKeyAuth": [] }
  ],
  "tags": [
    { "name": "Boutiques", "description": "Boutiques accessibles à la clé API (périmètre fail-closed)." },
    { "name": "Catalogue", "description": "Catégories et produits. Les prix sont calculés côté serveur." },
    { "name": "Commandes", "description": "Création et suivi des commandes (livraison / retrait)." },
    { "name": "Réservations", "description": "Création et suivi des réservations de table." },
    { "name": "Fidélité", "description": "Cartes de fidélité : consultation et ajustement de points." }
  ],
  "paths": {
    "/api/v1/outlets": {
      "get": {
        "tags": ["Boutiques"],
        "summary": "Lister les boutiques",
        "description": "Retourne les boutiques actives couvertes par la clé API.",
        "x-scope": "outlets:read",
        "responses": {
          "200": {
            "description": "Liste des boutiques.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["outlets"],
                  "properties": {
                    "outlets": { "type": "array", "items": { "$ref": "#/components/schemas/Outlet" } }
                  }
                },
                "example": {
                  "outlets": [
                    { "id": 3, "brand_id": 2, "name": "Mamma Mia Almadies", "city": "Dakar", "country": "SN", "currency": "XOF", "slug": "mammamia-almadies", "active": 1 },
                    { "id": 7, "brand_id": 2, "name": "Mamma Mia Plateau", "city": "Dakar", "country": "SN", "currency": "XOF", "slug": "mammamia-plateau", "active": 1 }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/categories": {
      "get": {
        "tags": ["Catalogue"],
        "summary": "Lister les catégories",
        "description": "Retourne les noms des catégories de produits actives.",
        "x-scope": "catalog:read",
        "responses": {
          "200": {
            "description": "Liste des catégories.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["categories"],
                  "properties": { "categories": { "type": "array", "items": { "type": "string" } } }
                },
                "example": { "categories": ["Pizzas", "Burgers", "Boissons", "Desserts"] }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/products": {
      "get": {
        "tags": ["Catalogue"],
        "summary": "Lister les produits",
        "description": "Catalogue paginé. Avec `outlet_id`, les prix et la disponibilité sont résolus pour cette boutique (doit être dans le périmètre de la clé).",
        "x-scope": "catalog:read",
        "parameters": [
          {
            "name": "outlet_id",
            "in": "query",
            "required": false,
            "description": "Identifiant de la boutique (prix et disponibilité spécifiques).",
            "schema": { "type": "integer" },
            "example": 3
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filtre exact sur le nom de la catégorie.",
            "schema": { "type": "string" },
            "example": "Pizzas"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Nombre d'éléments (1 à 500, défaut 100).",
            "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 100 },
            "example": 100
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Décalage de pagination (défaut 0).",
            "schema": { "type": "integer", "minimum": 0, "default": 0 },
            "example": 0
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée des produits.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["products", "limit", "offset"],
                  "properties": {
                    "products": { "type": "array", "items": { "$ref": "#/components/schemas/ProductListItem" } },
                    "limit": { "type": "integer" },
                    "offset": { "type": "integer" }
                  }
                },
                "example": {
                  "products": [
                    { "id": 42, "name": "Pizza 4 Fromages", "category": "Pizzas", "price": 6500, "currency": "XOF", "emoji": "🍕", "image_url": null, "available": true },
                    { "id": 87, "name": "Jus de Bissap", "category": "Boissons", "price": 1500, "currency": "XOF", "emoji": "🧃", "image_url": null, "available": true }
                  ],
                  "limit": 100,
                  "offset": 0
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/products/{id}": {
      "get": {
        "tags": ["Catalogue"],
        "summary": "Détail d'un produit",
        "description": "Fiche produit avec ses groupes d'options (suppléments, tailles…). Avec `outlet_id`, le prix est résolu pour cette boutique.",
        "x-scope": "catalog:read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identifiant du produit.",
            "schema": { "type": "integer" },
            "example": 42
          },
          {
            "name": "outlet_id",
            "in": "query",
            "required": false,
            "description": "Identifiant de la boutique (prix spécifique).",
            "schema": { "type": "integer" },
            "example": 3
          }
        ],
        "responses": {
          "200": {
            "description": "Fiche produit.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Product" },
                "example": {
                  "id": 42,
                  "name": "Pizza 4 Fromages",
                  "category": "Pizzas",
                  "price": 6500,
                  "currency": "XOF",
                  "description": "Mozzarella, gorgonzola, parmesan, chèvre.",
                  "emoji": "🍕",
                  "image_url": null,
                  "option_groups": [
                    {
                      "id": 9,
                      "prompt": "Suppléments",
                      "min_select": 0,
                      "max_select": 3,
                      "options": [
                        { "id": 31, "name": "Extra fromage", "price": 1000, "max_per_option": 2 },
                        { "id": 32, "name": "Champignons", "price": 800, "max_per_option": 2 }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/orders": {
      "post": {
        "tags": ["Commandes"],
        "summary": "Créer une commande",
        "description": "Crée une commande (livraison ou retrait) dans une boutique du périmètre. Les prix, les promos et les frais de livraison sont calculés côté serveur. La commande apparaît en caisse et en cuisine comme une commande web.",
        "x-scope": "orders:write",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Clé d'idempotence (UUID recommandé). Rejouer la même clé avec le même corps rejoue la réponse stockée (en-tête `Idempotent-Replayed: true`). La même clé avec un corps différent retourne 422 `cle_idempotence_reutilisee`.",
            "schema": { "type": "string" },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/OrderCreate" },
              "example": {
                "outlet_id": 3,
                "customer_name": "Awa Diallo",
                "customer_phone": "+221771234567",
                "type": "delivery",
                "address": "Sacré-Cœur 3, Villa 12, Dakar",
                "delivery_zone_id": 5,
                "items": [
                  { "product_id": 42, "qty": 2 },
                  { "product_id": 87, "qty": 1, "note": "Bien frais" }
                ],
                "customer_note": "Appeler à l'arrivée"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Commande créée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["order"],
                  "properties": { "order": { "$ref": "#/components/schemas/Order" } }
                },
                "example": {
                  "order": {
                    "id": 1024,
                    "reference": "API-X7K2M9PQ",
                    "outlet_id": 3,
                    "type": "delivery",
                    "status": "open",
                    "subtotal": 14500,
                    "delivery_fee": 1000,
                    "total": 15500,
                    "currency": "XOF",
                    "customer_name": "Awa Diallo",
                    "customer_phone": "+221771234567",
                    "address": "Sacré-Cœur 3, Villa 12, Dakar",
                    "customer_note": "Appeler à l'arrivée",
                    "created_at": "2026-10-01T18:30:00.000Z"
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": {
            "description": "Clé d'idempotence réutilisée avec un corps différent.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "cle_idempotence_reutilisee", "message": "Cette clé d'idempotence a déjà été utilisée avec un corps différent." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "get": {
        "tags": ["Commandes"],
        "summary": "Lister les commandes",
        "description": "Commandes des boutiques du périmètre, les plus récentes d'abord.",
        "x-scope": "orders:read",
        "parameters": [
          {
            "name": "outlet_id",
            "in": "query",
            "required": false,
            "description": "Filtre par boutique.",
            "schema": { "type": "integer" },
            "example": 3
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtre par statut (ex. `open`, `paid`, `cancelled`).",
            "schema": { "type": "string" },
            "example": "open"
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "Ne retourne que les commandes créées à partir de cette date (ISO 8601).",
            "schema": { "type": "string", "format": "date-time" },
            "example": "2026-10-01T00:00:00Z"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Nombre d'éléments (1 à 200, défaut 50).",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 },
            "example": 50
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Décalage de pagination (défaut 0).",
            "schema": { "type": "integer", "minimum": 0, "default": 0 },
            "example": 0
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée des commandes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["orders", "limit", "offset"],
                  "properties": {
                    "orders": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } },
                    "limit": { "type": "integer" },
                    "offset": { "type": "integer" }
                  }
                },
                "example": {
                  "orders": [
                    {
                      "id": 1024,
                      "reference": "API-X7K2M9PQ",
                      "outlet_id": 3,
                      "type": "delivery",
                      "status": "open",
                      "subtotal": 14500,
                      "delivery_fee": 1000,
                      "total": 15500,
                      "currency": "XOF",
                      "customer_name": "Awa Diallo",
                      "customer_phone": "+221771234567",
                      "address": "Sacré-Cœur 3, Villa 12, Dakar",
                      "customer_note": "Appeler à l'arrivée",
                      "created_at": "2026-10-01T18:30:00.000Z"
                    }
                  ],
                  "limit": 50,
                  "offset": 0
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/orders/{id}": {
      "get": {
        "tags": ["Commandes"],
        "summary": "Détail d'une commande",
        "description": "Commande avec le détail de ses lignes (articles, prix unitaires, promos).",
        "x-scope": "orders:read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identifiant de la commande.",
            "schema": { "type": "integer" },
            "example": 1024
          }
        ],
        "responses": {
          "200": {
            "description": "Commande et ses lignes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["order"],
                  "properties": { "order": { "$ref": "#/components/schemas/OrderDetail" } }
                },
                "example": {
                  "order": {
                    "id": 1024,
                    "reference": "API-X7K2M9PQ",
                    "outlet_id": 3,
                    "type": "delivery",
                    "status": "open",
                    "subtotal": 14500,
                    "delivery_fee": 1000,
                    "total": 15500,
                    "currency": "XOF",
                    "customer_name": "Awa Diallo",
                    "customer_phone": "+221771234567",
                    "address": "Sacré-Cœur 3, Villa 12, Dakar",
                    "customer_note": "Appeler à l'arrivée",
                    "created_at": "2026-10-01T18:30:00.000Z",
                    "items": [
                      { "id": 501, "product_id": 42, "product_name": "Pizza 4 Fromages", "qty": 2, "unit_price": 6500, "notes": null, "status": "pending", "promo_label": null, "promo_amount": 0 },
                      { "id": 502, "product_id": 87, "product_name": "Jus de Bissap", "qty": 1, "unit_price": 1500, "notes": "Bien frais", "status": "pending", "promo_label": null, "promo_amount": 0 }
                    ]
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/orders/{id}/cancel": {
      "post": {
        "tags": ["Commandes"],
        "summary": "Annuler une commande",
        "description": "Annule une commande non payée (le stock déduit est restauré). Un motif est obligatoire. Une commande déjà payée ne peut pas être annulée via l'API (409 `deja_payee`).",
        "x-scope": "orders:write",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identifiant de la commande.",
            "schema": { "type": "integer" },
            "example": 1024
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CancelReason" },
              "example": { "reason": "Client injoignable" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Commande annulée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["order"],
                  "properties": { "order": { "$ref": "#/components/schemas/Order" } }
                },
                "example": {
                  "order": {
                    "id": 1024,
                    "reference": "API-X7K2M9PQ",
                    "outlet_id": 3,
                    "type": "delivery",
                    "status": "cancelled",
                    "subtotal": 14500,
                    "delivery_fee": 1000,
                    "total": 15500,
                    "currency": "XOF",
                    "customer_name": "Awa Diallo",
                    "customer_phone": "+221771234567",
                    "address": "Sacré-Cœur 3, Villa 12, Dakar",
                    "customer_note": "Appeler à l'arrivée",
                    "created_at": "2026-10-01T18:30:00.000Z"
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "Conflit : déjà annulée ou déjà payée.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "deja_annulee": { "value": { "error": "deja_annulee", "message": "Commande déjà annulée." } },
                  "deja_payee": { "value": { "error": "deja_payee", "message": "Commande déjà payée, annulation impossible via API." } }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/reservations": {
      "post": {
        "tags": ["Réservations"],
        "summary": "Créer une réservation",
        "description": "Crée une réservation de table (statut `confirmee`). Le client est retrouvé par son téléphone, ou créé. La date doit être dans le futur (ISO 8601).",
        "x-scope": "reservations:write",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Clé d'idempotence (UUID recommandé). Rejouer la même clé avec le même corps rejoue la réponse stockée (en-tête `Idempotent-Replayed: true`). La même clé avec un corps différent retourne 422 `cle_idempotence_reutilisee`.",
            "schema": { "type": "string" },
            "example": "6ba7b811-9dad-11d1-80b4-00c04fd430c8"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ReservationCreate" },
              "example": {
                "outlet_id": 3,
                "customer_name": "Moussa Ndiaye",
                "customer_phone": "+221781234567",
                "party_size": 4,
                "reserved_for": "2026-10-05T20:00:00+00:00",
                "notes": "Table près de la fenêtre si possible"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Réservation créée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["reservation"],
                  "properties": { "reservation": { "$ref": "#/components/schemas/Reservation" } }
                },
                "example": {
                  "reservation": {
                    "id": 215,
                    "reference": "API-R-9F3A1B",
                    "outlet_id": 3,
                    "customer_id": 88,
                    "customer_name": "Moussa Ndiaye",
                    "party_size": 4,
                    "reserved_for": "2026-10-05T20:00:00.000Z",
                    "status": "confirmee",
                    "notes": "Table près de la fenêtre si possible",
                    "created_at": "2026-10-01T18:35:00.000Z"
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": {
            "description": "Clé d'idempotence réutilisée avec un corps différent.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "cle_idempotence_reutilisee", "message": "Cette clé d'idempotence a déjà été utilisée avec un corps différent." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "get": {
        "tags": ["Réservations"],
        "summary": "Lister les réservations",
        "description": "Réservations des boutiques du périmètre, triées par date croissante.",
        "x-scope": "reservations:read",
        "parameters": [
          {
            "name": "outlet_id",
            "in": "query",
            "required": false,
            "description": "Filtre par boutique.",
            "schema": { "type": "integer" },
            "example": 3
          },
          {
            "name": "date",
            "in": "query",
            "required": false,
            "description": "Filtre par jour (format AAAA-MM-JJ).",
            "schema": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
            "example": "2026-10-05"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtre par statut (ex. `confirmee`, `annulee`).",
            "schema": { "type": "string" },
            "example": "confirmee"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Nombre d'éléments (1 à 200, défaut 50).",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 },
            "example": 50
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Décalage de pagination (défaut 0).",
            "schema": { "type": "integer", "minimum": 0, "default": 0 },
            "example": 0
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée des réservations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["reservations", "limit", "offset"],
                  "properties": {
                    "reservations": { "type": "array", "items": { "$ref": "#/components/schemas/Reservation" } },
                    "limit": { "type": "integer" },
                    "offset": { "type": "integer" }
                  }
                },
                "example": {
                  "reservations": [
                    {
                      "id": 215,
                      "reference": "API-R-9F3A1B",
                      "outlet_id": 3,
                      "customer_id": 88,
                      "customer_name": "Moussa Ndiaye",
                      "party_size": 4,
                      "reserved_for": "2026-10-05T20:00:00.000Z",
                      "status": "confirmee",
                      "notes": "Table près de la fenêtre si possible",
                      "created_at": "2026-10-01T18:35:00.000Z"
                    }
                  ],
                  "limit": 50,
                  "offset": 0
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/reservations/{id}/cancel": {
      "post": {
        "tags": ["Réservations"],
        "summary": "Annuler une réservation",
        "description": "Passe la réservation au statut `annulee`.",
        "x-scope": "reservations:write",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Identifiant de la réservation.",
            "schema": { "type": "integer" },
            "example": 215
          }
        ],
        "responses": {
          "200": {
            "description": "Réservation annulée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["reservation"],
                  "properties": { "reservation": { "$ref": "#/components/schemas/Reservation" } }
                },
                "example": {
                  "reservation": {
                    "id": 215,
                    "reference": "API-R-9F3A1B",
                    "outlet_id": 3,
                    "customer_id": 88,
                    "customer_name": "Moussa Ndiaye",
                    "party_size": 4,
                    "reserved_for": "2026-10-05T20:00:00.000Z",
                    "status": "annulee",
                    "notes": "Table près de la fenêtre si possible",
                    "created_at": "2026-10-01T18:35:00.000Z"
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "Réservation déjà annulée.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "deja_annulee", "message": "Réservation déjà annulée." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/loyalty/card/{card_number}": {
      "get": {
        "tags": ["Fidélité"],
        "summary": "Consulter une carte de fidélité",
        "description": "Solde de points, niveau et portefeuille d'une carte (16 chiffres, espaces acceptés). N'expose aucune donnée personnelle au-delà du nom.",
        "x-scope": "loyalty:read",
        "parameters": [
          {
            "name": "card_number",
            "in": "path",
            "required": true,
            "description": "Numéro de la carte de fidélité.",
            "schema": { "type": "string" },
            "example": "1234567890123456"
          }
        ],
        "responses": {
          "200": {
            "description": "Carte trouvée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["card"],
                  "properties": { "card": { "$ref": "#/components/schemas/LoyaltyCard" } }
                },
                "example": {
                  "card": {
                    "card_number": "1234567890123456",
                    "customer_name": "Awa Diallo",
                    "tier": "Gold",
                    "points": 1250,
                    "wallet_balance": 5000
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/loyalty/card/{card_number}/points": {
      "post": {
        "tags": ["Fidélité"],
        "summary": "Ajuster les points d'une carte",
        "description": "Crédite ou débite des points (entier non nul, ±100 000 max). Un débit ne peut pas rendre le solde négatif (409 `solde_insuffisant`). Un motif est obligatoire (traçabilité).",
        "x-scope": "loyalty:write",
        "parameters": [
          {
            "name": "card_number",
            "in": "path",
            "required": true,
            "description": "Numéro de la carte de fidélité.",
            "schema": { "type": "string" },
            "example": "1234567890123456"
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Clé d'idempotence (UUID recommandé). Rejouer la même clé avec le même corps rejoue la réponse stockée (en-tête `Idempotent-Replayed: true`). La même clé avec un corps différent retourne 422 `cle_idempotence_reutilisee`.",
            "schema": { "type": "string" },
            "example": "9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/PointsAdjust" },
              "example": { "points": 100, "reason": "Parrainage octobre", "outlet_id": 3 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Points ajustés.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["card", "adjustment"],
                  "properties": {
                    "card": { "$ref": "#/components/schemas/LoyaltyCard" },
                    "adjustment": {
                      "type": "object",
                      "required": ["points", "reason"],
                      "properties": {
                        "points": { "type": "integer" },
                        "reason": { "type": "string" }
                      }
                    }
                  }
                },
                "example": {
                  "card": {
                    "card_number": "1234567890123456",
                    "customer_name": "Awa Diallo",
                    "tier": "Gold",
                    "points": 1350,
                    "wallet_balance": 5000
                  },
                  "adjustment": { "points": 100, "reason": "Parrainage octobre" }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "Solde insuffisant pour un débit.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "solde_insuffisant", "message": "Solde de points insuffisant." }
              }
            }
          },
          "422": {
            "description": "Clé d'idempotence réutilisée avec un corps différent.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "cle_idempotence_reutilisee", "message": "Cette clé d'idempotence a déjà été utilisée avec un corps différent." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "webhooks": {
    "order.created": {
      "post": {
        "summary": "order.created",
        "description": "Envoyé quand une commande est créée via l'API v1.\n\nEn-têtes : `Tibida-Event`, `Tibida-Timestamp` (epoch secondes), `Tibida-Signature`, `Tibida-Delivery` (id unique), `User-Agent: tibida-webhooks/1.0`.\n\nSignature : `HMAC-SHA256hex(secret_endpoint, timestamp + \".\" + corps_brut)`. Vérifiez la signature en temps constant et refusez les timestamps vieux de plus de 5 minutes (anti-rejeu).\n\nLivraison : 3 tentatives (0s, 5s, 30s), délai d'attente 10s. Répondez 2xx rapidement.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookEnvelope" },
              "example": {
                "event": "order.created",
                "delivered_at": "2026-10-01T18:30:01.000Z",
                "data": {
                  "id": 1024,
                  "reference": "API-X7K2M9PQ",
                  "outlet_id": 3,
                  "type": "delivery",
                  "status": "open",
                  "total": 15500,
                  "currency": "XOF",
                  "customer_name": "Awa Diallo",
                  "created_at": "2026-10-01T18:30:00.000Z"
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "Accusé de réception." } }
      }
    },
    "order.cancelled": {
      "post": {
        "summary": "order.cancelled",
        "description": "Envoyé quand une commande créée via l'API v1 est annulée. Mêmes en-têtes, signature et règles de livraison que `order.created`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookEnvelope" },
              "example": {
                "event": "order.cancelled",
                "delivered_at": "2026-10-01T19:02:00.000Z",
                "data": { "id": 1024, "reference": "API-X7K2M9PQ", "outlet_id": 3, "reason": "Client injoignable" }
              }
            }
          }
        },
        "responses": { "200": { "description": "Accusé de réception." } }
      }
    },
    "reservation.created": {
      "post": {
        "summary": "reservation.created",
        "description": "Envoyé quand une réservation est créée via l'API v1. Mêmes en-têtes, signature et règles de livraison que `order.created`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookEnvelope" },
              "example": {
                "event": "reservation.created",
                "delivered_at": "2026-10-01T18:35:01.000Z",
                "data": {
                  "id": 215,
                  "reference": "API-R-9F3A1B",
                  "outlet_id": 3,
                  "customer_name": "Moussa Ndiaye",
                  "party_size": 4,
                  "reserved_for": "2026-10-05T20:00:00.000Z",
                  "status": "confirmee"
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "Accusé de réception." } }
      }
    },
    "reservation.cancelled": {
      "post": {
        "summary": "reservation.cancelled",
        "description": "Envoyé quand une réservation créée via l'API v1 est annulée. Mêmes en-têtes, signature et règles de livraison que `order.created`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookEnvelope" },
              "example": {
                "event": "reservation.cancelled",
                "delivered_at": "2026-10-01T20:10:00.000Z",
                "data": { "id": 215, "reference": "API-R-9F3A1B", "outlet_id": 3 }
              }
            }
          }
        },
        "responses": { "200": { "description": "Accusé de réception." } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Clé API tibida (`tib_live_…`) dans l'en-tête `Authorization: Bearer <clé>`."
      },
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Alternative : clé API tibida (`tib_live_…`) dans l'en-tête `X-API-Key`."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Requête invalide.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "telephone_invalide", "message": "Numéro de téléphone invalide." }
          }
        }
      },
      "Unauthorized": {
        "description": "Clé API manquante, invalide, révoquée ou expirée.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "manquante": { "value": { "error": "cle_api_manquante", "message": "Clé API manquante (en-tête Authorization: Bearer <clé> ou X-API-Key)." } },
              "invalide": { "value": { "error": "cle_api_invalide", "message": "Clé API invalide." } },
              "revoquee": { "value": { "error": "cle_api_revoquee", "message": "Cette clé API a été révoquée." } },
              "expiree": { "value": { "error": "cle_api_expiree", "message": "Cette clé API a expiré." } }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Portée insuffisante ou boutique hors périmètre.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "portee": { "value": { "error": "portee_insuffisante", "message": "Portée insuffisante. Requise : orders:write." } },
              "perimetre": { "value": { "error": "boutique_hors_perimetre", "message": "Cette clé API n'a pas accès à cette boutique." } }
            }
          }
        }
      },
      "NotFound": {
        "description": "Ressource introuvable.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "commande_introuvable", "message": "Commande introuvable." }
          }
        }
      },
      "RateLimited": {
        "description": "Limite de débit dépassée (défaut : 120 requêtes/minute par clé).",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "trop_de_requetes", "message": "Trop de requêtes. Réessayez dans quelques instants." }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": { "type": "string", "description": "Code d'erreur (snake_case, en français).", "example": "cle_api_invalide" },
          "message": { "type": "string", "description": "Message lisible, en français.", "example": "Clé API invalide." }
        }
      },
      "Outlet": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 3 },
          "brand_id": { "type": ["integer", "null"], "example": 2 },
          "name": { "type": "string", "example": "Mamma Mia Almadies" },
          "city": { "type": ["string", "null"], "example": "Dakar" },
          "country": { "type": ["string", "null"], "example": "SN" },
          "currency": { "type": ["string", "null"], "example": "XOF" },
          "slug": { "type": ["string", "null"], "example": "mammamia-almadies" },
          "active": { "type": "integer", "example": 1 }
        }
      },
      "ProductListItem": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 42 },
          "name": { "type": "string", "example": "Pizza 4 Fromages" },
          "category": { "type": ["string", "null"], "example": "Pizzas" },
          "price": { "type": "number", "description": "Prix en francs CFA (calculé côté serveur).", "example": 6500 },
          "currency": { "type": "string", "example": "XOF" },
          "emoji": { "type": ["string", "null"], "example": "🍕" },
          "image_url": { "type": ["string", "null"], "example": null },
          "available": { "type": "boolean", "example": true }
        }
      },
      "ModifierOption": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 31 },
          "name": { "type": "string", "example": "Extra fromage" },
          "price": { "type": "number", "example": 1000 },
          "max_per_option": { "type": ["integer", "null"], "example": 2 }
        }
      },
      "OptionGroup": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 9 },
          "prompt": { "type": "string", "example": "Suppléments" },
          "min_select": { "type": "integer", "example": 0 },
          "max_select": { "type": "integer", "example": 3 },
          "options": { "type": "array", "items": { "$ref": "#/components/schemas/ModifierOption" } }
        }
      },
      "Product": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 42 },
          "name": { "type": "string", "example": "Pizza 4 Fromages" },
          "category": { "type": ["string", "null"], "example": "Pizzas" },
          "price": { "type": "number", "example": 6500 },
          "currency": { "type": "string", "example": "XOF" },
          "description": { "type": ["string", "null"], "example": "Mozzarella, gorgonzola, parmesan, chèvre." },
          "emoji": { "type": ["string", "null"], "example": "🍕" },
          "image_url": { "type": ["string", "null"], "example": null },
          "option_groups": { "type": "array", "items": { "$ref": "#/components/schemas/OptionGroup" } }
        }
      },
      "OrderItemInput": {
        "type": "object",
        "required": ["product_id", "qty"],
        "properties": {
          "product_id": { "type": "integer", "description": "Identifiant du produit.", "example": 42 },
          "qty": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Quantité (1 à 99).", "example": 2 },
          "note": { "type": "string", "maxLength": 200, "description": "Note de préparation (optionnel).", "example": "Bien frais" }
        }
      },
      "OrderCreate": {
        "type": "object",
        "required": ["outlet_id", "customer_name", "type", "items"],
        "properties": {
          "outlet_id": { "type": "integer", "description": "Boutique (doit être dans le périmètre de la clé).", "example": 3 },
          "customer_name": { "type": "string", "maxLength": 120, "description": "Nom du client.", "example": "Awa Diallo" },
          "customer_phone": { "type": "string", "description": "Téléphone du client (validé s'il est fourni, format E.164 recommandé).", "example": "+221771234567" },
          "type": { "type": "string", "enum": ["delivery", "pickup"], "description": "`delivery` = livraison, `pickup` = retrait.", "example": "delivery" },
          "address": { "type": "string", "maxLength": 300, "description": "Adresse de livraison (requise si `type` = `delivery`).", "example": "Sacré-Cœur 3, Villa 12, Dakar" },
          "delivery_zone_id": { "type": "integer", "description": "Zone de livraison (frais calculés côté serveur).", "example": 5 },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/OrderItemInput" }
          },
          "customer_note": { "type": "string", "maxLength": 500, "description": "Note du client.", "example": "Appeler à l'arrivée" }
        }
      },
      "Order": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 1024 },
          "reference": { "type": "string", "description": "Référence publique (ex. API-X7K2M9PQ).", "example": "API-X7K2M9PQ" },
          "outlet_id": { "type": "integer", "example": 3 },
          "type": { "type": "string", "enum": ["delivery", "pickup"], "example": "delivery" },
          "status": { "type": "string", "description": "Statut (`open`, `paid`, `cancelled`…).", "example": "open" },
          "subtotal": { "type": "number", "example": 14500 },
          "delivery_fee": { "type": "number", "example": 1000 },
          "total": { "type": "number", "example": 15500 },
          "currency": { "type": "string", "example": "XOF" },
          "customer_name": { "type": ["string", "null"], "example": "Awa Diallo" },
          "customer_phone": { "type": ["string", "null"], "example": "+221771234567" },
          "address": { "type": ["string", "null"], "example": "Sacré-Cœur 3, Villa 12, Dakar" },
          "customer_note": { "type": ["string", "null"], "example": "Appeler à l'arrivée" },
          "created_at": { "type": "string", "format": "date-time", "example": "2026-10-01T18:30:00.000Z" }
        }
      },
      "OrderItem": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 501 },
          "product_id": { "type": "integer", "example": 42 },
          "product_name": { "type": "string", "example": "Pizza 4 Fromages" },
          "qty": { "type": "integer", "example": 2 },
          "unit_price": { "type": "number", "example": 6500 },
          "notes": { "type": ["string", "null"], "example": null },
          "status": { "type": "string", "example": "pending" },
          "promo_label": { "type": ["string", "null"], "example": null },
          "promo_amount": { "type": "number", "example": 0 }
        }
      },
      "OrderDetail": {
        "type": "object",
        "description": "Commande avec le détail de ses lignes.",
        "allOf": [
          { "$ref": "#/components/schemas/Order" },
          {
            "type": "object",
            "properties": {
              "items": { "type": "array", "items": { "$ref": "#/components/schemas/OrderItem" } }
            }
          }
        ]
      },
      "CancelReason": {
        "type": "object",
        "required": ["reason"],
        "properties": {
          "reason": { "type": "string", "maxLength": 200, "description": "Motif d'annulation (obligatoire, traçabilité).", "example": "Client injoignable" }
        }
      },
      "ReservationCreate": {
        "type": "object",
        "required": ["outlet_id", "customer_name", "customer_phone", "party_size", "reserved_for"],
        "properties": {
          "outlet_id": { "type": "integer", "description": "Boutique (doit être dans le périmètre de la clé).", "example": 3 },
          "customer_name": { "type": "string", "maxLength": 120, "example": "Moussa Ndiaye" },
          "customer_phone": { "type": "string", "description": "Téléphone du client (format E.164 recommandé).", "example": "+221781234567" },
          "party_size": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Nombre de convives (1 à 100).", "example": 4 },
          "reserved_for": { "type": "string", "format": "date-time", "description": "Date et heure dans le futur (ISO 8601).", "example": "2026-10-05T20:00:00+00:00" },
          "notes": { "type": "string", "maxLength": 500, "example": "Table près de la fenêtre si possible" }
        }
      },
      "Reservation": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 215 },
          "reference": { "type": "string", "example": "API-R-9F3A1B" },
          "outlet_id": { "type": "integer", "example": 3 },
          "customer_id": { "type": ["integer", "null"], "example": 88 },
          "customer_name": { "type": ["string", "null"], "example": "Moussa Ndiaye" },
          "party_size": { "type": "integer", "example": 4 },
          "reserved_for": { "type": "string", "format": "date-time", "example": "2026-10-05T20:00:00.000Z" },
          "status": { "type": "string", "description": "Statut (`confirmee`, `annulee`…).", "example": "confirmee" },
          "notes": { "type": ["string", "null"], "example": "Table près de la fenêtre si possible" },
          "created_at": { "type": "string", "format": "date-time", "example": "2026-10-01T18:35:00.000Z" }
        }
      },
      "LoyaltyCard": {
        "type": "object",
        "properties": {
          "card_number": { "type": "string", "example": "1234567890123456" },
          "customer_name": { "type": ["string", "null"], "example": "Awa Diallo" },
          "tier": { "type": "string", "description": "Niveau (Classic, Gold, Platinum, Noir).", "example": "Gold" },
          "points": { "type": "integer", "example": 1250 },
          "wallet_balance": { "type": "number", "description": "Solde du portefeuille prépayé (FCFA).", "example": 5000 }
        }
      },
      "PointsAdjust": {
        "type": "object",
        "required": ["points", "reason"],
        "properties": {
          "points": { "type": "integer", "description": "Entier non nul, ±100 000 max. Positif = crédit, négatif = débit.", "example": 100 },
          "reason": { "type": "string", "maxLength": 200, "description": "Motif (obligatoire, traçabilité).", "example": "Parrainage octobre" },
          "outlet_id": { "type": "integer", "description": "Boutique de rattachement (optionnel).", "example": 3 }
        }
      },
      "WebhookEnvelope": {
        "type": "object",
        "required": ["event", "delivered_at", "data"],
        "properties": {
          "event": { "type": "string", "enum": ["order.created", "order.cancelled", "reservation.created", "reservation.cancelled"], "example": "order.created" },
          "delivered_at": { "type": "string", "format": "date-time", "example": "2026-10-01T18:30:01.000Z" },
          "data": { "type": "object", "description": "Charge utile de l'événement (commande ou réservation)." }
        }
      }
    }
  }
}
