{
  "openapi": "3.1.0",
  "info": {
    "title": "API caisse.bzh",
    "version": "1.2.0",
    "summary": "Carte publique, commande à emporter et synchronisation signée du catalogue et du stock.",
    "description": "L'API de caisse.bzh, la caisse en ligne des restaurants, bars et commerces.\n\nTrois familles d'appels :\n\n- **Publics** (`/api/carte`, `/api/order`) : sans authentification, pour afficher la carte d'un établissement et lui transmettre une commande à emporter. Réservés aux métiers qui ont une carte publique (restaurant, bar).\n- **API avec clé** (`/api/v1/…`) : catalogue, stock, ventes et commandes en ligne d'un établissement, avec une clé créée dans la console (page « Accès API »). En-tête `Authorization: Bearer cbz_live_…`.\n- **Synchronisation** (`/api/sync`) : appels signés HMAC-SHA256 entre une boutique en ligne et la caisse. La boutique envoie son catalogue et ses mouvements de stock ; la caisse renvoie ses ventes et remboursements sur le webhook de la boutique.\n\n**User-Agent.** Chaque appel porte un `User-Agent` qui nomme votre outil (`ma-boutique-sync/1.0`). Le pare-feu écarte les robots d'analyse et les scripts anonymes : les User-Agent par défaut des bibliothèques HTTP de Python (urllib, requests), Go et Java reçoivent un 403 `{\"error\":\"blocked\"}`.\n\nGuide : https://caisse.bzh/developpeurs · SDK JavaScript : https://caisse.bzh/sdk/caisse-bzh.mjs",
    "contact": {
      "name": "caisse.bzh",
      "email": "bonjour@caisse.bzh",
      "url": "https://caisse.bzh/developpeurs"
    }
  },
  "servers": [
    {
      "url": "https://caisse.bzh"
    }
  ],
  "externalDocs": {
    "description": "Guide développeurs",
    "url": "https://caisse.bzh/developpeurs"
  },
  "tags": [
    {
      "name": "Carte publique",
      "description": "Lecture de la carte et commande à emporter, sans authentification."
    },
    {
      "name": "API avec clé",
      "description": "Les données d'un établissement, pour vos propres outils. Une clé = un établissement ; droits lecture seule ou lecture et écriture ; 120 appels par minute et par clé."
    },
    {
      "name": "Synchronisation",
      "description": "Catalogue et stock entre une boutique en ligne et la caisse, appels signés."
    }
  ],
  "paths": {
    "/api/carte": {
      "get": {
        "tags": [
          "Carte publique"
        ],
        "operationId": "getCarte",
        "summary": "Lire la carte d'un établissement",
        "description": "Les mêmes informations que la page publique `/carte/<slug>` : identité, horaires, catégories et articles. Les `id` d'articles servent à passer commande. Réponse mise en cache 60 secondes. Ouvert aux navigateurs de tout site (CORS).",
        "parameters": [
          {
            "name": "slug",
            "in": "query",
            "required": true,
            "description": "Identifiant public de l'établissement, celui de son adresse `/carte/<slug>`.",
            "schema": {
              "type": "string",
              "maxLength": 64
            },
            "example": "comptoir-brest"
          }
        ],
        "responses": {
          "200": {
            "description": "La carte.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Carte"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PublicError"
          },
          "404": {
            "$ref": "#/components/responses/PublicError"
          }
        },
        "security": []
      }
    },
    "/api/order": {
      "post": {
        "tags": [
          "Carte publique"
        ],
        "operationId": "placeOrder",
        "summary": "Passer une commande à emporter",
        "description": "La commande arrive dans la file « Commandes en ligne » de l'établissement, qui l'accepte puis la marque prête. Les prix et le total sont toujours recalculés par la caisse à partir de la carte : un montant envoyé est ignoré, une ligne dont l'article est inconnu est retirée. Paiement au retrait. À appeler depuis un serveur : l'appel n'est pas ouvert aux navigateurs d'autres sites (CORS). 60 commandes par tranche de 10 minutes et par établissement.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Commande enregistrée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "order"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "order": {
                      "$ref": "#/components/schemas/PlacedOrder"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PublicError"
          },
          "404": {
            "$ref": "#/components/responses/PublicError"
          },
          "502": {
            "$ref": "#/components/responses/PublicError"
          },
          "429": {
            "$ref": "#/components/responses/PublicError"
          },
          "409": {
            "$ref": "#/components/responses/PublicError"
          },
          "422": {
            "$ref": "#/components/responses/PublicError"
          }
        },
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/v1/shop": {
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "getShop",
        "summary": "L'établissement de la clé",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "L'établissement, ses fonctions et la clé employée.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "shop",
                    "features",
                    "key"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "shop": {
                      "$ref": "#/components/schemas/Shop"
                    },
                    "features": {
                      "type": "object",
                      "properties": {
                        "onlineOrders": {
                          "type": "boolean"
                        },
                        "stock": {
                          "type": "boolean"
                        }
                      }
                    },
                    "key": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "scope": {
                          "enum": [
                            "read",
                            "write"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/catalogue": {
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "getCatalogue",
        "summary": "Catégories et articles",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Le catalogue complet, déclinaisons et options comprises.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "categories",
                    "items"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "categories": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Category"
                      }
                    },
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Item"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/catalogue/items": {
      "post": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "createItem",
        "summary": "Créer un article",
        "description": "Clé en lecture et écriture.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ItemCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Article créé.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "item"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "item": {
                      "$ref": "#/components/schemas/Item"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "400": {
            "$ref": "#/components/responses/ApiError"
          },
          "403": {
            "$ref": "#/components/responses/ApiError"
          },
          "409": {
            "$ref": "#/components/responses/ApiError"
          },
          "422": {
            "$ref": "#/components/responses/ApiError"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/v1/catalogue/items/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "getItem",
        "summary": "Lire un article",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "L'article.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "item"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "item": {
                      "$ref": "#/components/schemas/Item"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      },
      "patch": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "updateItem",
        "summary": "Modifier un article",
        "description": "Seuls les champs envoyés changent ; une chaîne vide efface `description`, `sku` ou `barcode`. Un article synchronisé avec une boutique en ligne (`managedBy: sync`) se modifie sur la boutique : 409 `managed_by_sync`.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ItemUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Article modifié.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "item"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "item": {
                      "$ref": "#/components/schemas/Item"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "400": {
            "$ref": "#/components/responses/ApiError"
          },
          "403": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          },
          "409": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      },
      "delete": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "deleteItem",
        "summary": "Supprimer un article",
        "description": "409 `managed_by_sync` pour un article synchronisé, 409 `last_item` pour le dernier article d'une carte de restaurant.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Article supprimé.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "deleted"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "deleted": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "403": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          },
          "409": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/stock": {
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "getStock",
        "summary": "Stock des articles synchronisés",
        "description": "Pour les commerces qui suivent un stock (404 `not_available` sinon). Le stock est tenu pour les articles reçus d'une boutique en ligne ; il bouge avec les ventes en caisse et les mouvements de la boutique.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Quantité par clé de synchronisation.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "items"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/StockLine"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/sales": {
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "listSales",
        "summary": "Ventes encaissées",
        "description": "Les tickets encaissés, du plus récent au plus ancien (environ les 2000 derniers). Pour la comptabilité, le journal fiscal scellé de la console fait foi.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Premier jour, AAAA-MM-JJ."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Dernier jour inclus."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Les ventes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "count",
                    "hasMore",
                    "sales"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "count": {
                      "type": "integer"
                    },
                    "hasMore": {
                      "type": "boolean"
                    },
                    "sales": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Sale"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "400": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/orders": {
      "get": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "listOrders",
        "summary": "Commandes en ligne",
        "description": "Pour les métiers à carte publique (404 `not_available` sinon).",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "enum": [
                "new",
                "accepted",
                "ready",
                "collected",
                "cancelled"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Les commandes, plus récentes d'abord.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "orders"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "orders": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Order"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "400": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/v1/orders/{id}/{action}": {
      "post": {
        "tags": [
          "API avec clé"
        ],
        "operationId": "updateOrder",
        "summary": "Faire avancer une commande",
        "description": "`accept` envoie la commande à l'écran cuisine ; `collected` et `cancel` la retirent de l'écran. Clé en lecture et écriture.",
        "security": [
          {
            "ApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "action",
            "in": "path",
            "required": true,
            "schema": {
              "enum": [
                "accept",
                "ready",
                "collected",
                "cancel"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La commande à jour.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "order"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "order": {
                      "$ref": "#/components/schemas/Order"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ApiError"
          },
          "429": {
            "$ref": "#/components/responses/ApiError"
          },
          "403": {
            "$ref": "#/components/responses/ApiError"
          },
          "404": {
            "$ref": "#/components/responses/ApiError"
          }
        }
      }
    },
    "/api/sync": {
      "post": {
        "tags": [
          "Synchronisation"
        ],
        "operationId": "syncMessage",
        "summary": "Envoyer un message signé à la caisse",
        "description": "Point d'entrée unique de la synchronisation, du côté de la caisse. Le jeton et le secret se trouvent dans la console, page « Boutique en ligne », où l'on renseigne aussi l'adresse HTTPS de votre webhook.\n\n**Signature.** `x-wd29-signature` = HMAC-SHA256 hexadécimal, avec le secret, de la chaîne `1\\n{timestamp}\\n{nonce}\\n{corps brut}`. L'horodatage (secondes Unix) doit être à moins de 300 secondes de l'heure de la caisse. Sans signature valide, la réponse est 401 et rien n'est lu ni écrit ; un jeton inconnu reçoit la même réponse.\n\n**Idempotence.** Chaque événement porte un `id` de 32 caractères hexadécimaux ; un événement déjà reçu est accepté sans être appliqué une seconde fois.\n\n**Sens inverse.** Quand la caisse vend ou rembourse un article synchronisé, elle envoie à votre webhook un message signé de la même façon (`source: \"ps\"`, voir le schéma `TillMessage`). Répondez `{\"ok\": true}` en 2xx ; sinon la caisse réessaie plus tard, dans l'ordre.",
        "security": [
          {
            "SyncSignature": []
          }
        ],
        "parameters": [
          {
            "name": "shop",
            "in": "query",
            "required": true,
            "description": "Jeton de la boutique (24 caractères hexadécimaux).",
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{24}$"
            }
          },
          {
            "name": "x-wd29-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[0-9]+$"
            },
            "description": "Secondes Unix."
          },
          {
            "name": "x-wd29-nonce",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{32}$"
            },
            "description": "16 octets aléatoires, en hexadécimal."
          }
        ],
        "requestBody": {
          "required": true,
          "description": "4 Mo au plus.",
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/SyncHealthRequest"
                  },
                  {
                    "$ref": "#/components/schemas/SyncEventsRequest"
                  },
                  {
                    "$ref": "#/components/schemas/SyncTickRequest"
                  }
                ],
                "discriminator": {
                  "propertyName": "op",
                  "mapping": {
                    "health": "#/components/schemas/SyncHealthRequest",
                    "events": "#/components/schemas/SyncEventsRequest",
                    "tick": "#/components/schemas/SyncTickRequest"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Message traité.",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/SyncHealth"
                    },
                    {
                      "$ref": "#/components/schemas/SyncAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/SyncOk"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/SyncError"
          },
          "401": {
            "$ref": "#/components/responses/SyncError"
          },
          "503": {
            "$ref": "#/components/responses/SyncError"
          }
        }
      }
    }
  },
  "webhooks": {
    "tillStock": {
      "post": {
        "tags": [
          "Synchronisation"
        ],
        "operationId": "tillToShop",
        "summary": "Ventes et remboursements envoyés par la caisse",
        "description": "Appel signé de la caisse vers votre webhook, mêmes en-têtes `x-wd29-*` et même calcul de signature, avec le secret de la boutique. Vérifiez la signature (fonction `verifySignature` du SDK) avant de lire le corps, et refusez un nonce déjà vu.",
        "parameters": [
          {
            "name": "x-wd29-timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-wd29-nonce",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-wd29-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TillMessage"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu. Le corps doit contenir `ok: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SyncOk"
                }
              }
            }
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "securitySchemes": {
      "SyncSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "x-wd29-signature",
        "description": "HMAC-SHA256 hexadécimal (64 caractères) de `1\\n{x-wd29-timestamp}\\n{x-wd29-nonce}\\n{corps brut}` avec le secret de la boutique. Ce n'est pas une clé fixe : elle se calcule pour chaque requête (fonction `signedHeaders` du SDK)."
      },
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "cbz_live_<40 hex>",
        "description": "Clé créée dans la console, page « Accès API ». Elle ne vaut que pour son établissement."
      }
    },
    "responses": {
      "PublicError": {
        "description": "Requête refusée. Codes : `no_slug`, `bad_json`, `unknown_shop` (404 : établissement inconnu ou sans carte publique), `no_name`, `no_items`, `persist_failed` (502), `rate_limited` (429, voir `Retry-After`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PublicError"
            }
          }
        }
      },
      "SyncError": {
        "description": "`bridge_auth` (401 : jeton ou signature), `bridge_request` (400 : message invalide ; `message` vaut `disabled` si la synchronisation est coupée dans la console, `platform` si `source` n'est pas `woo`, `unsupported` pour une opération inconnue), `stock_busy` (503 : réessayer).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/SyncError"
            }
          }
        }
      },
      "ApiError": {
        "description": "`unauthorized` (401 : clé absente, inconnue ou révoquée), `read_only_key` (403), `not_found` (404), `not_available` (404 : fonction absente pour ce métier), `invalid_*` et `bad_json` (400), `managed_by_sync` et `last_item` (409), `rate_limited` (429, voir `Retry-After`).",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "ok",
                "error"
              ],
              "properties": {
                "ok": {
                  "const": false
                },
                "error": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "PublicError": {
        "type": "object",
        "required": [
          "ok",
          "error"
        ],
        "properties": {
          "ok": {
            "const": false
          },
          "error": {
            "type": "string"
          }
        }
      },
      "WeekHours": {
        "type": "object",
        "required": [
          "tz",
          "days"
        ],
        "properties": {
          "tz": {
            "type": "string",
            "example": "Europe/Paris"
          },
          "days": {
            "type": "object",
            "description": "Clés `mon` à `sun`. Une plage dont la fermeture précède l'ouverture se poursuit après minuit.",
            "additionalProperties": {
              "type": "object",
              "required": [
                "closed",
                "ranges"
              ],
              "properties": {
                "closed": {
                  "type": "boolean"
                },
                "ranges": {
                  "type": "array",
                  "maxItems": 3,
                  "items": {
                    "type": "array",
                    "prefixItems": [
                      {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$"
                      },
                      {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$"
                      }
                    ],
                    "minItems": 2,
                    "maxItems": 2
                  }
                }
              }
            }
          }
        }
      },
      "Carte": {
        "type": "object",
        "required": [
          "ok",
          "shop",
          "hours",
          "openNow",
          "categories",
          "items"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "shop": {
            "type": "object",
            "required": [
              "slug",
              "name",
              "currency"
            ],
            "properties": {
              "slug": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "tagline": {
                "type": "string"
              },
              "address": {
                "type": "string"
              },
              "phone": {
                "type": "string"
              },
              "currency": {
                "type": "string",
                "example": "EUR"
              }
            }
          },
          "hours": {
            "$ref": "#/components/schemas/WeekHours"
          },
          "openNow": {
            "type": "boolean",
            "description": "Ouvert à l'instant, dans le fuseau de l'établissement. Une commande reste possible fermé : elle est alors une précommande."
          },
          "categories": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "icon": {
                  "type": "string"
                }
              }
            }
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "categoryId",
                "name",
                "price"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "description": "À reprendre dans `items[].itemId` de la commande."
                },
                "categoryId": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "price": {
                  "type": "number",
                  "description": "Prix TTC en euros."
                },
                "description": {
                  "type": "string"
                },
                "tags": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "example": [
                    "végé"
                  ]
                }
              }
            }
          }
        }
      },
      "OrderInput": {
        "type": "object",
        "required": [
          "slug",
          "customerName",
          "items"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "maxLength": 64
          },
          "customerName": {
            "type": "string",
            "maxLength": 80
          },
          "phone": {
            "type": "string",
            "maxLength": 30
          },
          "pickupAt": {
            "type": "string",
            "maxLength": 40,
            "description": "`HH:MM` ou un libellé court. Vide : « Dès que possible »."
          },
          "note": {
            "type": "string",
            "maxLength": 200
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 40,
            "items": {
              "type": "object",
              "required": [
                "itemId"
              ],
              "properties": {
                "itemId": {
                  "type": "string"
                },
                "qty": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 20,
                  "default": 1
                },
                "note": {
                  "type": "string",
                  "maxLength": 200
                }
              }
            }
          }
        },
        "example": {
          "slug": "comptoir-brest",
          "customerName": "Léna",
          "phone": "06 12 34 56 78",
          "pickupAt": "12:30",
          "items": [
            {
              "itemId": "en2",
              "qty": 1
            }
          ]
        }
      },
      "PlacedOrder": {
        "type": "object",
        "required": [
          "id",
          "no",
          "pickupAt",
          "total",
          "status",
          "payment"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "no": {
            "type": "integer",
            "description": "Numéro du jour, celui qu'annonce le comptoir."
          },
          "pickupAt": {
            "type": "string"
          },
          "total": {
            "type": "number",
            "description": "TTC en euros, recalculé par la caisse."
          },
          "status": {
            "const": "nouveau"
          },
          "payment": {
            "type": "object",
            "properties": {
              "method": {
                "const": "onsite"
              },
              "status": {
                "const": "pending"
              }
            }
          }
        }
      },
      "SyncEnvelope": {
        "type": "object",
        "required": [
          "version",
          "source",
          "op"
        ],
        "properties": {
          "version": {
            "const": 1
          },
          "source": {
            "const": "woo",
            "description": "Le côté boutique en ligne du protocole, quelle que soit votre plateforme."
          },
          "op": {
            "type": "string"
          }
        }
      },
      "SyncHealthRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/SyncEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "op": {
                "const": "health"
              }
            }
          }
        ],
        "description": "Répond même si la synchronisation est coupée dans la console."
      },
      "SyncTickRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/SyncEnvelope"
          },
          {
            "type": "object",
            "properties": {
              "op": {
                "const": "tick"
              }
            }
          }
        ],
        "description": "Réveille la caisse : elle renvoie aussitôt les mouvements de stock en attente vers votre webhook."
      },
      "SyncEventsRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/SyncEnvelope"
          },
          {
            "type": "object",
            "required": [
              "events"
            ],
            "properties": {
              "op": {
                "const": "events"
              },
              "events": {
                "type": "array",
                "maxItems": 20,
                "items": {
                  "$ref": "#/components/schemas/SyncEvent"
                }
              }
            }
          }
        ]
      },
      "RecordKey": {
        "type": "string",
        "pattern": "^(woo|ps):(product|variant):[1-9][0-9]*$",
        "description": "Clé d'un article ou d'une déclinaison de votre boutique : `woo:product:<id>` ou `woo:variant:<id>`, id entier positif.",
        "example": "woo:product:408"
      },
      "SyncEvent": {
        "type": "object",
        "required": [
          "id",
          "kind",
          "payload"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-f0-9]{32}$",
            "description": "Clé d'idempotence."
          },
          "kind": {
            "enum": [
              "product",
              "stock",
              "order",
              "customer"
            ],
            "description": "`order` et `customer` sont acceptés sans être conservés."
          },
          "key": {
            "$ref": "#/components/schemas/RecordKey"
          },
          "payload": {
            "oneOf": [
              {
                "type": "object",
                "required": [
                  "data"
                ],
                "properties": {
                  "data": {
                    "$ref": "#/components/schemas/ProductSnapshot"
                  }
                },
                "title": "product"
              },
              {
                "type": "object",
                "required": [
                  "delta"
                ],
                "properties": {
                  "delta": {
                    "type": "integer"
                  }
                },
                "title": "stock (variation)"
              },
              {
                "type": "object",
                "required": [
                  "set_mode",
                  "quantity"
                ],
                "properties": {
                  "set_mode": {
                    "const": true
                  },
                  "quantity": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "null : stock non suivi."
                  }
                },
                "title": "stock (quantité)"
              }
            ]
          }
        }
      },
      "Prices": {
        "type": "object",
        "properties": {
          "regular": {
            "type": [
              "number",
              "string",
              "null"
            ]
          },
          "sale": {
            "type": [
              "number",
              "string",
              "null"
            ],
            "description": "Prioritaire sur `regular` s'il est renseigné."
          },
          "tax_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "TVA en pourcentage (20, 10, 5.5)."
          },
          "basis": {
            "enum": [
              "gross",
              "net"
            ],
            "default": "gross",
            "description": "`net` : prix HT, la caisse ajoute la TVA."
          }
        }
      },
      "Identifiers": {
        "type": "object",
        "properties": {
          "ean13": {
            "type": "string"
          },
          "gtin": {
            "type": "string"
          },
          "upc": {
            "type": "string"
          }
        }
      },
      "ProductSnapshot": {
        "type": "object",
        "required": [
          "key"
        ],
        "description": "Instantané complet d'un article : il remplace l'article en caisse. Les articles saisis à la main en caisse ne sont jamais touchés.",
        "properties": {
          "key": {
            "$ref": "#/components/schemas/RecordKey"
          },
          "name": {
            "type": "string",
            "maxLength": 80
          },
          "status": {
            "type": "string",
            "description": "Seul `publish` garde l'article en caisse."
          },
          "deleted": {
            "type": "boolean"
          },
          "archived": {
            "type": "boolean"
          },
          "sku": {
            "type": "string"
          },
          "categories": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Chemins de catégories ; la caisse garde le premier niveau du plus long.",
            "example": [
              [
                "Vêtements",
                "Pantalons"
              ]
            ]
          },
          "prices": {
            "$ref": "#/components/schemas/Prices"
          },
          "identifiers": {
            "$ref": "#/components/schemas/Identifiers"
          },
          "variants": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "key",
                "status"
              ],
              "properties": {
                "key": {
                  "$ref": "#/components/schemas/RecordKey"
                },
                "status": {
                  "type": "string"
                },
                "attributes": {
                  "type": "object",
                  "additionalProperties": {
                    "type": "string"
                  },
                  "example": {
                    "taille": "M",
                    "couleur": "Bleu"
                  }
                },
                "sku": {
                  "type": "string"
                },
                "prices": {
                  "$ref": "#/components/schemas/Prices"
                },
                "identifiers": {
                  "$ref": "#/components/schemas/Identifiers"
                }
              }
            }
          },
          "inventory": {
            "type": "array",
            "description": "Quantités de départ, prises en compte seulement pour une clé encore inconnue. Ensuite, seuls les événements `stock` font bouger le stock.",
            "items": {
              "type": "object",
              "required": [
                "key",
                "quantity"
              ],
              "properties": {
                "key": {
                  "$ref": "#/components/schemas/RecordKey"
                },
                "quantity": {
                  "type": [
                    "integer",
                    "null"
                  ]
                }
              }
            }
          }
        }
      },
      "SyncOk": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "const": true
          }
        }
      },
      "SyncAccepted": {
        "type": "object",
        "required": [
          "ok",
          "accepted"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "accepted": {
            "type": "integer"
          }
        }
      },
      "SyncHealth": {
        "type": "object",
        "required": [
          "ok",
          "protocol",
          "mode"
        ],
        "properties": {
          "ok": {
            "const": true
          },
          "protocol": {
            "const": 1
          },
          "platform": {
            "const": "ps"
          },
          "software": {
            "const": "caisse.bzh"
          },
          "mode": {
            "enum": [
              "live",
              "disabled"
            ]
          },
          "diagnostics": {
            "type": "object",
            "properties": {
              "ok": {
                "type": "boolean"
              },
              "mode": {
                "type": "string"
              },
              "queue": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "direction": {
                      "const": "out"
                    },
                    "state": {
                      "const": "pending"
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              },
              "issues": {
                "type": "array",
                "items": {}
              }
            }
          }
        }
      },
      "SyncError": {
        "type": "object",
        "required": [
          "ok",
          "code"
        ],
        "properties": {
          "ok": {
            "const": false
          },
          "code": {
            "enum": [
              "bridge_auth",
              "bridge_request"
            ]
          },
          "message": {
            "type": "string"
          }
        }
      },
      "TillMessage": {
        "type": "object",
        "required": [
          "version",
          "source",
          "op"
        ],
        "properties": {
          "version": {
            "const": 1
          },
          "source": {
            "const": "ps",
            "description": "La caisse."
          },
          "op": {
            "enum": [
              "events",
              "tick"
            ],
            "description": "`tick` : la caisse vient d'envoyer des mouvements, appliquez votre file."
          },
          "events": {
            "type": "array",
            "description": "Un événement par appel, dans l'ordre. `delta` négatif pour une vente, positif pour un remboursement.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "kind",
                "key",
                "payload"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[a-f0-9]{32}$"
                },
                "kind": {
                  "const": "stock"
                },
                "key": {
                  "$ref": "#/components/schemas/RecordKey"
                },
                "payload": {
                  "type": "object",
                  "required": [
                    "delta"
                  ],
                  "properties": {
                    "delta": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "Shop": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "city": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "siret": {
            "type": "string"
          },
          "vatNumber": {
            "type": "string"
          },
          "kind": {
            "enum": [
              "restaurant",
              "bar",
              "boutique",
              "alimentaire"
            ]
          }
        }
      },
      "Category": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "icon": {
            "type": "string"
          },
          "course": {
            "enum": [
              "starter",
              "main",
              "dessert",
              "drink"
            ]
          },
          "vatRate": {
            "type": "number",
            "description": "0.2 pour 20 %."
          }
        }
      },
      "Item": {
        "type": "object",
        "required": [
          "id",
          "categoryId",
          "name",
          "price",
          "managedBy"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "categoryId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "price": {
            "type": "number",
            "description": "€ TTC"
          },
          "description": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sku": {
            "type": "string"
          },
          "barcode": {
            "type": "string"
          },
          "variants": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "price": {
                  "type": "number"
                },
                "sku": {
                  "type": "string"
                },
                "barcode": {
                  "type": "string"
                },
                "syncKey": {
                  "type": "string"
                }
              }
            }
          },
          "modifiers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "required": {
                  "type": "boolean"
                },
                "multiple": {
                  "type": "boolean"
                },
                "options": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "name": {
                        "type": "string"
                      },
                      "price": {
                        "type": "number"
                      }
                    }
                  }
                }
              }
            }
          },
          "syncKey": {
            "type": "string",
            "description": "Clé de l'article dans la boutique en ligne reliée."
          },
          "managedBy": {
            "enum": [
              "caisse",
              "sync"
            ],
            "description": "`sync` : l'article vient de la boutique en ligne et ne se modifie que là-bas."
          }
        }
      },
      "ItemCreate": {
        "type": "object",
        "required": [
          "name",
          "price",
          "categoryId"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "price": {
            "type": "number",
            "minimum": 0,
            "maximum": 100000,
            "description": "€ TTC"
          },
          "categoryId": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "maxLength": 200
          },
          "tags": {
            "type": "array",
            "maxItems": 6,
            "items": {
              "type": "string",
              "maxLength": 24
            }
          },
          "sku": {
            "type": "string",
            "maxLength": 64
          },
          "barcode": {
            "type": "string",
            "maxLength": 32,
            "pattern": "^[0-9A-Za-z-]*$"
          }
        },
        "example": {
          "name": "Galette complète",
          "price": 11.5,
          "categoryId": "plats",
          "tags": [
            "signature"
          ]
        }
      },
      "ItemUpdate": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "price": {
            "type": "number",
            "minimum": 0,
            "maximum": 100000,
            "description": "€ TTC"
          },
          "categoryId": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "maxLength": 200
          },
          "tags": {
            "type": "array",
            "maxItems": 6,
            "items": {
              "type": "string",
              "maxLength": 24
            }
          },
          "sku": {
            "type": "string",
            "maxLength": 64
          },
          "barcode": {
            "type": "string",
            "maxLength": 32,
            "pattern": "^[0-9A-Za-z-]*$"
          }
        },
        "example": {
          "price": 12
        }
      },
      "StockLine": {
        "type": "object",
        "required": [
          "syncKey",
          "quantity"
        ],
        "properties": {
          "syncKey": {
            "$ref": "#/components/schemas/RecordKey"
          },
          "quantity": {
            "type": [
              "integer",
              "null"
            ],
            "description": "null : stock non suivi."
          },
          "itemId": {
            "type": "string"
          },
          "variantId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "sku": {
            "type": "string"
          }
        }
      },
      "Sale": {
        "type": "object",
        "required": [
          "id",
          "number",
          "day",
          "settledAt",
          "total",
          "items",
          "payments"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "number": {
            "type": "integer",
            "description": "Numéro de ticket du jour."
          },
          "fiscalNumber": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Numéro dans le journal fiscal scellé."
          },
          "day": {
            "type": "string",
            "format": "date"
          },
          "settledAt": {
            "type": "string",
            "format": "date-time"
          },
          "settledBy": {
            "type": "string"
          },
          "type": {
            "enum": [
              "table",
              "counter"
            ]
          },
          "label": {
            "type": "string"
          },
          "covers": {
            "type": "integer"
          },
          "subtotal": {
            "type": "number"
          },
          "discount": {
            "type": "number"
          },
          "total": {
            "type": "number",
            "description": "€ TTC"
          },
          "tip": {
            "type": "number"
          },
          "loyaltyRedemption": {
            "type": "number",
            "description": "€ réglés en points de fidélité."
          },
          "vat": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "rate": {
                  "type": "number"
                },
                "gross": {
                  "type": "number"
                },
                "net": {
                  "type": "number"
                },
                "vat": {
                  "type": "number"
                }
              }
            }
          },
          "payments": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "method": {
                  "type": "string",
                  "description": "cash, card, mobile, voucher…"
                },
                "amount": {
                  "type": "number"
                }
              }
            }
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "qty": {
                  "type": "number"
                },
                "unitPrice": {
                  "type": "number"
                },
                "total": {
                  "type": "number"
                },
                "vatRate": {
                  "type": "number"
                },
                "itemId": {
                  "type": "string"
                },
                "variantId": {
                  "type": "string"
                },
                "sku": {
                  "type": "string"
                }
              }
            }
          },
          "training": {
            "type": "boolean",
            "description": "Vente du mode formation, hors chiffre d'affaires."
          }
        }
      },
      "Order": {
        "type": "object",
        "required": [
          "id",
          "number",
          "status",
          "items",
          "total"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "number": {
            "type": "integer"
          },
          "status": {
            "enum": [
              "new",
              "accepted",
              "ready",
              "collected",
              "cancelled"
            ]
          },
          "customerName": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "pickupAt": {
            "type": "string"
          },
          "note": {
            "type": "string"
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "itemId": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "qty": {
                  "type": "integer"
                },
                "unitPrice": {
                  "type": "number"
                },
                "note": {
                  "type": "string"
                }
              }
            }
          },
          "total": {
            "type": "number"
          },
          "payment": {
            "type": "object",
            "properties": {
              "method": {
                "enum": [
                  "onsite",
                  "card"
                ]
              },
              "status": {
                "enum": [
                  "pending",
                  "paid"
                ]
              }
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 100
        },
        "description": "Clé libre (un UUID par exemple). Une requête renvoyée avec la même clé dans les 24 heures reçoit la première réponse (en-tête `Idempotent-Replayed: true`) au lieu de créer un doublon. Même clé pour une requête différente : 422 `idempotency_key_reused` ; requête identique encore en cours : 409 `idempotency_in_progress`."
      }
    }
  }
}
