{
  "openapi": "3.1.0",
  "info": {
    "title": "Digibite API v2",
    "version": "2026-10-01",
    "summary": "Ventas, jornadas y catálogo para integradores",
    "description": "API de lectura para back offices y ERP.\n\n> ⚠️ **VISTA PREVIA DEL CONTRATO — todavía no está desplegada.** Este documento se publica para que puedas empezar a construir contra él. Los endpoints **aún no responden**; te avisaremos con la fecha y con tus credenciales de pruebas. El contrato descrito aquí no cambiará sin avisar.\n\n## La unidad es la JORNADA, no el día natural\n\nUn restaurante cierra de madrugada: un ticket de las 02:00 del sábado pertenece a la jornada del viernes. Cada restaurante tiene su hora de corte y su zona horaria, y `GET /v2/business_days` te las dice. **Pide siempre por `business_day`**, nunca construyas tú un rango de fechas: te partiría una jornada por la mitad.\n\n## Cómo sincronizar\n\n1. `GET /v2/business_days` para saber qué jornadas hay y cuáles están cerradas.\n2. Para cada jornada, `GET /v2/orders`, siguiendo `next_page` hasta que `has_more` sea `false`.\n3. Una jornada `open` **se relee entera** en cada pasada. Una `closed` se lee una vez.\n\nUna jornada se marca `closed` dos horas después de terminar su ventana. Ese margen existe porque hay ventas que llegan tarde.\n\n> **Lo que esta versión NO resuelve, dicho por delante:** una jornada ya cerrada que cambia después —una devolución sobre una venta de la semana pasada— no queda señalada por ninguna parte. Si necesitas detectarlo, hay que hablarlo: implica un contador de revisión por jornada.\n\n## Importes\n\nTodos en **céntimos**, enteros, y **siempre positivos**. El signo lo lleva la naturaleza del documento: un `object: \"return\"` es una salida. Nunca sumes ventas y devoluciones sin mirar el `object`.\n\n## Errores\n\nSobre `{ \"error\": { \"code\", \"message\", \"docs_url\", \"retryable\", \"request_id\" } }`. `401` = no sé quién eres · `403` = sé quién eres y no puedes · `404` = existe el concepto, no ese id (o está fuera de tu ámbito).",
    "contact": {
      "name": "Integraciones Digibite",
      "email": "integrations@digibite.app",
      "url": "https://developers.digibite.app"
    }
  },
  "servers": [
    { "url": "https://api.digibite.app", "description": "Producción (aún no activo)" }
  ],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Sincronización", "description": "Las jornadas: por dónde empezar." },
    { "name": "Ventas", "description": "Pedidos completos y devoluciones." },
    { "name": "Catálogo", "description": "El maestro de artículos, para mapear." }
  ],
  "paths": {
    "/v2/business_days": {
      "get": {
        "tags": ["Sincronización"],
        "summary": "Jornadas de un restaurante",
        "description": "Enumera las jornadas y su ventana UTC. No lee datos de venta: es barato y puedes llamarlo a menudo.\n\n`status` se calcula por regla, no se guarda: una jornada pasa sola a `closed` cuando termina su ventana más dos horas de margen.",
        "operationId": "listBusinessDays",
        "security": [{ "bearerAuth": ["sync:read"] }],
        "parameters": [
          {
            "name": "restaurant_id",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Obligatorio: la ventana depende de la zona y el corte de CADA restaurante."
          },
          {
            "name": "from",
            "in": "query",
            "schema": { "type": "string", "format": "date" },
            "description": "Por defecto, el horizonte de datos (2025-10-16). Antes de esa fecha no hay datos completos."
          },
          {
            "name": "to",
            "in": "query",
            "schema": { "type": "string", "format": "date" },
            "description": "Por defecto, hoy **en la zona del restaurante**."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 400, "default": 100 }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de jornadas",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": { "const": "list" },
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/BusinessDay" }
                    },
                    "has_more": { "type": "boolean" },
                    "next_from": { "type": ["string", "null"] }
                  }
                },
                "example": {
                  "object": "list",
                  "data": [
                    {
                      "object": "business_day",
                      "business_day": "2026-08-19",
                      "restaurant_id": "rest_9aK7",
                      "status": "closed",
                      "window_start": "2026-08-19T02:00:00.000Z",
                      "window_end": "2026-08-20T02:00:00.000Z",
                      "timezone": "Europe/Madrid",
                      "cutoff_local": "04:00"
                    }
                  ],
                  "has_more": false,
                  "next_from": null
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v2/orders": {
      "get": {
        "tags": ["Ventas"],
        "summary": "Pedidos y devoluciones de una jornada",
        "description": "Devuelve el **pedido completo**, con sus líneas anidadas — no hace falta una segunda llamada por pedido.\n\nLa respuesta mezcla dos tipos: mira siempre `object` para distinguir `order` de `return`.\n\n### Combos\n\nUn combo lleva el precio en la línea padre; sus componentes van a `total: 0` porque su recargo ya está dentro del padre. **La cantidad de cada componente viene ya multiplicada** por la del padre: un menú ×3 con 2 pechugas trae `quantity: 6`.\n\nPara consumo: suma `quantity` donde `consumes_stock` sea `true`. Para facturación: suma `total` donde `revenue_bearing` sea `true`. Nunca mezcles los dos ejes.\n\n### Descuentos\n\n`order.discount` es **informativo**. El importe efectivo ya está descontado en el `total` de cada línea: si lo restas otra vez, descuentas dos veces.\n\n### Devoluciones\n\nAparecen en la jornada en que se **emitieron**, no en la de la venta que rectifican. `original_bill_id` las une con su venta.\n\n`restores_stock` distingue lo que vuelve al inventario de la **merma**: en una merma el género no vuelve, así que revierte el ingreso pero **no el coste**.",
        "operationId": "listOrders",
        "security": [{ "bearerAuth": ["sales:lines:read"] }],
        "parameters": [
          { "name": "restaurant_id", "in": "query", "required": true, "schema": { "type": "string" } },
          {
            "name": "business_day",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "format": "date" },
            "description": "La jornada, tal y como la devuelve `/v2/business_days`."
          },
          {
            "name": "include",
            "in": "query",
            "schema": { "type": "string", "enum": ["sales", "returns", "all"], "default": "all" },
            "description": "Por defecto `all`. Con `sales` te dejas fuera las devoluciones y tu escandallo saldrá alto: `meta.includes_returns` te lo recuerda en cada respuesta."
          },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } },
          {
            "name": "page",
            "in": "query",
            "schema": { "type": "string" },
            "description": "El `next_page` de la respuesta anterior. Es opaco y va firmado: no lo construyas ni lo edites."
          }
        ],
        "responses": {
          "200": {
            "description": "Pedidos y devoluciones de la jornada",
            "headers": {
              "X-Business-Day-Status": {
                "schema": { "type": "string", "enum": ["open", "closed"] },
                "description": "Si es `open`, lo que acabas de importar es provisional."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": { "const": "list" },
                    "data": {
                      "type": "array",
                      "items": {
                        "oneOf": [
                          { "$ref": "#/components/schemas/Order" },
                          { "$ref": "#/components/schemas/Return" }
                        ]
                      }
                    },
                    "has_more": { "type": "boolean" },
                    "next_page": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "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" }
        }
      }
    },
    "/v2/catalog/items": {
      "get": {
        "tags": ["Catálogo"],
        "summary": "Maestro de artículos",
        "description": "El catálogo de la **organización**. Es donde debes anclar tu mapeo: el `id` de aquí es el mismo que viaja en `lines[].item_id` de los pedidos, así que mapeas una vez y entiendes todas las ventas.\n\n**Ánclalo aquí y no a una carta.** Del maestro no desaparece nada; una carta es un derivado que cambia a lo largo del día.\n\n**No lleva precio.** El precio de venta solo existe dentro de una tarifa, y varía por restaurante y por modo: publicarlo aquí sería darte un número que no es el que se cobra.\n\nLos artículos inactivos **sí salen**, con `active: false`: siguen apareciendo en ventas históricas.",
        "operationId": "listCatalogItems",
        "security": [{ "bearerAuth": ["catalog:read"] }],
        "parameters": [
          {
            "name": "lang",
            "in": "query",
            "schema": { "type": "string", "enum": ["es", "en", "de", "fr", "pt", "ru", "sv", "zh"], "default": "es" }
          },
          { "name": "include_inactive", "in": "query", "schema": { "type": "boolean", "default": true } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 200 } },
          { "name": "page", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Artículos del maestro",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": { "const": "list" },
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } },
                    "has_more": { "type": "boolean" },
                    "next_page": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer dgb_live_…`\n\nLa clave **nunca** puede viajar en la URL: si la mandas como `?api_key=` responde `400`. Acaba en logs de acceso, en el historial y en la cabecera `Referer`."
      }
    },
    "schemas": {
      "Money": {
        "type": "object",
        "description": "Importe en céntimos, entero y SIEMPRE positivo.",
        "properties": {
          "amount": { "type": "integer", "example": 1210 },
          "currency": { "type": "string", "example": "EUR" }
        }
      },
      "BusinessDay": {
        "type": "object",
        "properties": {
          "object": { "const": "business_day" },
          "business_day": { "type": "string", "format": "date" },
          "restaurant_id": { "type": "string" },
          "status": { "type": "string", "enum": ["open", "closed"] },
          "window_start": { "type": "string", "format": "date-time" },
          "window_end": { "type": "string", "format": "date-time", "description": "Exclusivo." },
          "timezone": { "type": "string" },
          "cutoff_local": { "type": "string", "example": "04:00" }
        }
      },
      "OrderLine": {
        "type": "object",
        "properties": {
          "line_id": { "type": "string" },
          "root_line_id": { "type": "string", "description": "Línea raíz del árbol: aplanar es un `concat`." },
          "parent_line_id": { "type": ["string", "null"] },
          "line_type": { "type": "string", "enum": ["product", "combo", "combo_component", "modifier"] },
          "item_id": { "type": ["string", "null"], "description": "Casa con el `id` de `/v2/catalog/items`." },
          "item_name": { "type": "string" },
          "quantity": { "type": "number", "description": "YA multiplicada por todos sus ancestros." },
          "consumes_stock": { "type": "boolean", "description": "Suma por aquí para el CONSUMO." },
          "revenue_bearing": { "type": "boolean", "description": "Suma por aquí para el DINERO." },
          "total": { "$ref": "#/components/schemas/Money" },
          "tax_rate_bps": { "type": "integer", "description": "Puntos básicos: 21 % = 2100." },
          "tax_amount": { "$ref": "#/components/schemas/Money" },
          "zero_revenue_reason": { "type": ["string", "null"] },
          "components": { "type": "array", "items": { "$ref": "#/components/schemas/OrderLine" } },
          "modifiers": { "type": "array", "items": { "$ref": "#/components/schemas/OrderLine" } }
        }
      },
      "Order": {
        "type": "object",
        "properties": {
          "object": { "const": "order" },
          "id": { "type": "string" },
          "business_day": { "type": "string", "format": "date" },
          "restaurant_id": { "type": "string" },
          "channel": { "type": ["string", "null"], "example": "pos" },
          "mode": { "type": ["string", "null"], "example": "eat_here" },
          "status": { "type": ["string", "null"] },
          "payment_status": { "type": ["string", "null"] },
          "ordered_at": { "type": ["string", "null"], "format": "date-time" },
          "is_store": { "type": "boolean" },
          "total": { "$ref": "#/components/schemas/Money" },
          "discount": { "$ref": "#/components/schemas/Money" },
          "tax_total": { "$ref": "#/components/schemas/Money" },
          "lines": {
            "type": ["array", "null"],
            "items": { "$ref": "#/components/schemas/OrderLine" },
            "description": "`null` si tu credencial solo tiene `sales:orders:read`. Un array vacío significaría un pedido sin líneas, que es otra cosa."
          },
          "control": {
            "type": "object",
            "description": "Para que puedas comprobar el cuadre por tu cuenta.",
            "properties": {
              "lines_total": { "$ref": "#/components/schemas/Money" },
              "lines_total_delta": {
                "type": "integer",
                "description": "`total` menos la suma de las líneas con `revenue_bearing`. Debe ser 0; si no lo es, te lo decimos en vez de taparlo."
              },
              "rows_count": { "type": "integer" },
              "quantity_consumed": { "type": "number" }
            }
          }
        }
      },
      "Return": {
        "type": "object",
        "properties": {
          "object": { "const": "return" },
          "id": { "type": "string" },
          "business_day": { "type": "string", "format": "date", "description": "Jornada de EMISIÓN, no la de la venta." },
          "restaurant_id": { "type": "string" },
          "number": { "type": ["string", "null"], "description": "Número de la rectificativa." },
          "original_bill_id": { "type": ["string", "null"] },
          "original_number": { "type": ["string", "null"] },
          "reason": { "type": ["string", "null"], "enum": ["defective", "order_error", "charge_error", "customer_changed_mind", "duplicate_charge", "other", null] },
          "restores_stock": {
            "type": "boolean",
            "description": "`false` solo en merma (`defective`): el género no vuelve al inventario, así que revierte el ingreso pero NO el coste."
          },
          "rectification_type": { "type": ["string", "null"], "enum": ["S", "I", null] },
          "issued_at": { "type": ["string", "null"], "format": "date-time" },
          "total": { "$ref": "#/components/schemas/Money" },
          "tax_total": { "$ref": "#/components/schemas/Money" },
          "lines": { "type": "array", "items": { "type": "object" } }
        }
      },
      "CatalogItem": {
        "type": "object",
        "properties": {
          "object": { "const": "catalog_item" },
          "id": { "type": "string", "description": "Ánclale aquí tu mapeo." },
          "name": { "type": "string" },
          "description": { "type": "string" },
          "lang": { "type": "string" },
          "active": { "type": "boolean", "description": "Vigente en el maestro. NO significa \"se vende hoy\"." },
          "category_ids": { "type": "array", "items": { "type": "string" } },
          "allergens": { "type": "array", "items": { "type": "string" } },
          "barcode": { "type": ["string", "null"] },
          "kitchen_name": { "type": ["string", "null"] }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "string" },
              "message": { "type": "string" },
              "docs_url": { "type": "string" },
              "retryable": { "type": "boolean" },
              "request_id": { "type": "string", "description": "Mándanoslo si nos escribes: con él encontramos tu petición." }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Petición mal formada. Un parámetro que no reconocemos se rechaza en vez de ignorarse, para que no creas haber filtrado algo que no filtraste.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Credencial ausente, mal formada, caducada, revocada, del entorno equivocado o desde una IP no permitida. La respuesta es la MISMA en todos los casos, a propósito.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "Tu credencial es válida pero le falta el permiso. El mensaje te dice cuál.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "El recurso no existe, o está fuera del ámbito de tu credencial. No los distinguimos: hacerlo confirmaría qué ids existen en otras organizaciones.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Demasiadas peticiones. Reintenta cuando indique `Retry-After`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
