openapi: 3.1.0
info:
  title: API de auditoría de facturas de MuninCloud
  version: 1.3.0
  summary: Sube una factura, recibe su lectura auditada en JSON (`v1`).
  description: |
    Contrato público `v1`, versión `1.3`. `v1.1` añadió `documento.direccion` y `factura.recargo_total`; `v1.2`, el
    nombre de la empresa en las llaves de procesar; y `v1.3` hace ese nombre **obligatorio** y pide en cada subida su
    `tipo`, `compra` o `venta`.

    **Cómo se usa, en cuatro pasos**

    1. Tras la compra recibes un **enlace de recogida de un solo uso**. Al abrirlo generas tu **llave de
       administrar** (`mc_live_…`), que se ve **una vez**. Nunca te la mandaremos por correo.
    2. Con la llave de administrar creas una **llave de procesar por cada empresa** cuyas facturas vayas a
       enviar (`POST /v1/llaves`), indicando su NIF y su nombre o razón social (los dos obligatorios: con
       ellos el motor reconoce qué parte de la factura es tu empresa). Puedes limitar cada llave a unas IP.
    3. Con una llave de procesar subes facturas (`POST /v1/trabajos`), **diciendo en cada una si es una
       `compra` o una `venta`** de esa empresa, y recibes un **número de trabajo**.
       El proceso es **asíncrono**: tarda de segundos a unos minutos.
    4. Consultas el trabajo (`GET /v1/trabajos/{trabajo}`) hasta que esté `terminado` (con su documento `v1`)
       o `rechazado` (con su error). Respeta la cabecera `Retry-After` entre consultas.

    **Qué entra.** Solo facturas, recibidas o emitidas: un PDF, JPEG o PNG de hasta 15 MB. Se leen como mucho
    5 páginas. Un documento con varias facturas se rechaza (`MULTIFACTURA`): un proceso es una factura. Lo
    que no es una factura (un ticket, una nómina, un extracto, un acta…) se rechaza (`NO_ES_FACTURA`). Al
    subirla dices si es una `compra` (la empresa de la llave la recibe) o una `venta` (la emite): la factura se lee
    así y la respuesta lo repite en `documento.direccion` (`RECIBIDA` o `EMITIDA`). Si en el papel la empresa
    aparece en el lado contrario, se lee igual como dijiste y el documento lleva el aviso `TIPO_DISCREPANTE`.

    **Qué se cobra.** Cada factura que se entrega `terminada` descuenta un proceso de tu pool. Un trabajo
    `rechazado` o fallido **no** descuenta.

    **Qué guardamos.** Nada de la factura. El resultado se conserva **24 horas** desde que termina, para que
    lo recojas; después, el trabajo ya no existe (`NO_ENCONTRADO`).

    **El documento `v1`** está descrito en `respuesta.schema.json`. Regla de `v1`: se pueden **añadir**
    campos, nunca quitarlos ni renombrarlos. Tu programa debe **ignorar los campos que no conozca** y tratar
    un código de aviso que no conozca como «revise el documento».

    **Los errores** siempre llevan un `codigo` estable (el que debe mirar tu programa) y un `mensaje` para
    personas. Esta API **no usa** los códigos HTTP 400, 403 ni 404: una ráfaga de ellos hace que el
    cortafuegos de entrada bloquee tu IP durante horas. Los fallos de llave son 401; los de la pool, 402; los
    de ritmo, 429; los del documento, 413, 415 o 422; y si el motor de lectura no está disponible en ese
    momento, 503 con `Retry-After` (al subir y al consultar). La documentación trae la tabla de todos los
    errores, con un ejemplo de cada uno.
  contact:
    name: MuninCloud
    url: https://munincloud.com
servers:
  - url: https://api.munincloud.com
    description: Producción.
security:
  - llave: []
tags:
  - name: trabajos
    description: Subir facturas y recoger su resultado. Llave de procesar.
  - name: pool
    description: El saldo del mes. Cualquier llave de la pool.
  - name: llaves
    description: Crear, listar, limitar por IP y anular las llaves de procesar. Llave de administrar.
  - name: recogida
    description: El enlace de un solo uso que entrega la llave de administrar tras el cobro. Sin llave.
paths:
  /v1/trabajos:
    post:
      tags: [trabajos]
      operationId: subirFactura
      summary: Subir una factura
      description: |
        Encola una factura y devuelve su número de trabajo (asíncrono). Pide una **llave de procesar**.
        El NIF de la empresa es el de la llave: no se manda en la petición.

        **Idempotencia.** Si repites la subida con la misma cabecera `Idempotency-Key` (y la misma llave) en
        menos de 24 horas, recibes **el mismo trabajo** y no se descuenta otra vez. Úsala si reintentas tras
        un corte de red.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [factura, tipo]
              properties:
                tipo:
                  type: string
                  enum: [compra, venta]
                  description: |
                    Obligatorio desde `v1.3`. `compra` si la factura la RECIBE la empresa de la llave (es el cliente);
                    `venta` si la EMITE (es el proveedor). Decide cómo se lee la factura entera. Si en el documento la
                    empresa aparece en el papel contrario, se lee igual como se ha indicado y el documento lleva el
                    aviso `TIPO_DISCREPANTE`.
                factura:
                  type: string
                  contentMediaType: application/octet-stream
                  description: El fichero. PDF, JPEG o PNG; como mucho 15 MB; se leen hasta 5 páginas.
            encoding:
              factura:
                contentType: application/pdf, image/jpeg, image/png
      responses:
        '202':
          description: Aceptado. El trabajo está en curso.
          headers:
            Location:
              description: La URL del trabajo.
              schema: {type: string}
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
            X-Pool-Disponibles:
              $ref: '#/components/headers/PoolDisponibles'
            X-Pool-Estado:
              $ref: '#/components/headers/PoolEstado'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Trabajo'}
              examples:
                en_curso: {$ref: '#/components/examples/TrabajoEnCurso'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '402': {$ref: '#/components/responses/PoolSinSaldo'}
        '413': {$ref: '#/components/responses/DocumentoGrande'}
        '415': {$ref: '#/components/responses/DocumentoFormato'}
        '422': {$ref: '#/components/responses/DocumentoOPeticion'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
        '503': {$ref: '#/components/responses/NoDisponible'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS https://api.munincloud.com/v1/trabajos \
              -H "Authorization: Bearer $MC_LLAVE_PROCESAR" \
              -H "Idempotency-Key: 7f6c1b0e-factura-0001" \
              -F "tipo=compra" \
              -F "factura=@factura.pdf;type=application/pdf"
  /v1/trabajos/{trabajo}:
    get:
      tags: [trabajos]
      operationId: consultarTrabajo
      summary: Consultar un trabajo
      description: |
        Devuelve el trabajo **siempre con 200** mientras exista, en uno de tres estados:

        - `en_curso`: vuelve a preguntar tras los segundos de `Retry-After`;
        - `terminado`: `documento` trae la factura auditada `v1` (`respuesta.schema.json`), con sus `avisos`;
        - `rechazado`: `error` dice por qué (`NO_ES_FACTURA`, `MULTIFACTURA`, `DOCUMENTO_NO_VALIDO`,
          `ERROR_INTERNO`). Un trabajo rechazado no descuenta de la pool.

        Solo la llave que lo subió puede verlo. Un trabajo que no existe, que es de otra llave o que ya caducó
        (24 h tras terminar) contesta lo mismo: `410 NO_ENCONTRADO`.

        Si el trabajo terminó pero el motor de lectura no puede devolver el resultado en ese momento, la
        consulta contesta `503` con `Retry-After`: el trabajo sigue ahí; vuelve a consultar pasados esos
        segundos (dentro de las 24 h).
      parameters:
        - $ref: '#/components/parameters/TrabajoId'
      responses:
        '200':
          description: El trabajo, en curso, terminado o rechazado.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
            X-Pool-Disponibles:
              $ref: '#/components/headers/PoolDisponibles'
            X-Pool-Estado:
              $ref: '#/components/headers/PoolEstado'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Trabajo'}
              examples:
                en_curso: {$ref: '#/components/examples/TrabajoEnCurso'}
                terminado: {$ref: '#/components/examples/TrabajoTerminado'}
                rechazado_no_es_factura: {$ref: '#/components/examples/TrabajoNoEsFactura'}
                rechazado_multifactura: {$ref: '#/components/examples/TrabajoMultifactura'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '410': {$ref: '#/components/responses/NoEncontrado'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
        '503': {$ref: '#/components/responses/ResultadoNoDisponible'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS https://api.munincloud.com/v1/trabajos/trb_01J9ZQ3K4T7W2B5N8C6D0F1G2H \
              -H "Authorization: Bearer $MC_LLAVE_PROCESAR"
  /v1/pool:
    get:
      tags: [pool]
      operationId: consultarPool
      summary: Consultar la pool
      description: |
        El saldo del mes y el estado de la pool de la llave. Vale cualquier llave de la pool,
        de procesar o de administrar.

        - `activa`: le queda saldo y procesa.
        - `parada`: el plan del mes y las recargas, a cero; no procesa (`POOL_AGOTADA`). Al recargar
          vuelve a funcionar **con las
          mismas llaves**. Tras 3 meses parada sin recargar, se cierra.
        - `cerrada`: definitivo; sus llaves están anuladas.

        `aviso_80` se pone a `true` cuando se ha gastado más del 80 % de lo disponible en el mes.
      responses:
        '200':
          description: La pool.
          headers:
            X-Pool-Disponibles:
              $ref: '#/components/headers/PoolDisponibles'
            X-Pool-Estado:
              $ref: '#/components/headers/PoolEstado'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Pool'}
              examples:
                activa: {$ref: '#/components/examples/PoolActiva'}
                parada: {$ref: '#/components/examples/PoolParada'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '402': {$ref: '#/components/responses/PoolCerrada'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS https://api.munincloud.com/v1/pool \
              -H "Authorization: Bearer $MC_LLAVE_PROCESAR"
  /v1/llaves:
    get:
      tags: [llaves]
      operationId: listarLlaves
      summary: Listar las llaves de la pool
      description: Todas las llaves de la pool, vivas y anuladas. **Nunca** el secreto. Pide la llave de administrar.
      responses:
        '200':
          description: Las llaves.
          headers:
            X-Pool-Disponibles:
              $ref: '#/components/headers/PoolDisponibles'
            X-Pool-Estado:
              $ref: '#/components/headers/PoolEstado'
          content:
            application/json:
              schema:
                type: object
                required: [llaves]
                additionalProperties: false
                properties:
                  llaves:
                    type: array
                    items: {$ref: '#/components/schemas/Llave'}
              examples:
                dos_llaves: {$ref: '#/components/examples/ListaLlaves'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS https://api.munincloud.com/v1/llaves \
              -H "Authorization: Bearer $MC_LLAVE_ADMINISTRAR"
    post:
      tags: [llaves]
      operationId: crearLlave
      summary: Crear una llave de procesar
      description: |
        Crea una llave de procesar para **una empresa** (su NIF y su nombre, obligatorios desde `v1.3`). La
        llave completa se devuelve **una sola vez**, en esta respuesta: guárdala; nosotros solo conservamos su
        huella. Pide la llave de administrar. Una pool cerrada no emite llaves (`POOL_CERRADA`).
        El NIF y el nombre se fijan al crear la llave y no se cambian: para otra empresa, otra llave.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LlaveNueva'}
            examples:
              con_ips:
                value: {cif: B00000018, nombre: TEST SL, ips_permitidas: ["203.0.113.10", "198.51.100.0/24"]}
              con_nombre:
                value: {cif: B00000018, nombre: TEST SL}
      responses:
        '201':
          description: Creada. `llave` no se volverá a mostrar.
          headers:
            X-Pool-Disponibles:
              $ref: '#/components/headers/PoolDisponibles'
            X-Pool-Estado:
              $ref: '#/components/headers/PoolEstado'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LlaveCreada'}
              examples:
                creada: {$ref: '#/components/examples/LlaveCreada'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '402': {$ref: '#/components/responses/PoolCerrada'}
        '422': {$ref: '#/components/responses/PeticionNoValida'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS https://api.munincloud.com/v1/llaves \
              -H "Authorization: Bearer $MC_LLAVE_ADMINISTRAR" \
              -H "Content-Type: application/json" \
              -d '{"cif": "B00000018", "nombre": "TEST SL", "ips_permitidas": ["203.0.113.10"]}'
  /v1/llaves/{llave}:
    patch:
      tags: [llaves]
      operationId: limitarLlavePorIp
      summary: Cambiar la lista de IP de una llave
      description: |
        Pone o quita la lista de IP permitidas de una llave. Lista vacía = sin límite. Vale para las de
        procesar y para la propia de administrar. Pide la llave de administrar.
      parameters:
        - $ref: '#/components/parameters/LlaveId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ips_permitidas]
              additionalProperties: false
              properties:
                ips_permitidas: {$ref: '#/components/schemas/ListaIps'}
            examples:
              una_ip:
                value: {ips_permitidas: ["203.0.113.10"]}
      responses:
        '200':
          description: La llave, con su lista nueva.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Llave'}
              examples:
                limitada: {$ref: '#/components/examples/LlaveProcesar'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '410': {$ref: '#/components/responses/NoEncontrado'}
        '422': {$ref: '#/components/responses/PeticionNoValida'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS -X PATCH https://api.munincloud.com/v1/llaves/k7Q2mX9pL4sA \
              -H "Authorization: Bearer $MC_LLAVE_ADMINISTRAR" \
              -H "Content-Type: application/json" \
              -d '{"ips_permitidas": ["203.0.113.10"]}'
    delete:
      tags: [llaves]
      operationId: anularLlave
      summary: Anular una llave de procesar
      description: |
        Anula una llave de procesar. Es definitivo: desde ese momento contesta `LLAVE_ANULADA`. Las demás
        llaves siguen funcionando. Para cambiar una llave sin corte: crea la nueva, ponla en tu programa,
        comprueba que entra y anula la vieja. La llave de administrar no se anula por la API (escríbenos).
      parameters:
        - $ref: '#/components/parameters/LlaveId'
      responses:
        '200':
          description: Anulada.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Llave'}
              examples:
                anulada: {$ref: '#/components/examples/LlaveAnulada'}
        '401': {$ref: '#/components/responses/LlaveNoValida'}
        '410': {$ref: '#/components/responses/NoEncontrado'}
        '422': {$ref: '#/components/responses/PeticionNoValida'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            curl -sS -X DELETE https://api.munincloud.com/v1/llaves/k7Q2mX9pL4sA \
              -H "Authorization: Bearer $MC_LLAVE_ADMINISTRAR"
  /v1/recogida:
    get:
      tags: [recogida]
      operationId: paginaRecogida
      summary: La página de recogida
      description: |
        La página a la que lleva el enlace de un solo uso: `https://api.munincloud.com/v1/recogida#vale=<vale>`.
        El vale va **detrás de `#`**, así que el navegador no lo envía al servidor al abrir la página y no
        queda en ningún registro. La página lo lee y, al pulsar «Generar mi llave», lo manda con `POST`.
      security: []
      responses:
        '200':
          description: La página (HTML). No contiene ningún secreto.
          content:
            text/html:
              schema: {type: string}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            # Se abre en el navegador; con curl solo se ve la página, sin vale:
            curl -sS https://api.munincloud.com/v1/recogida
    post:
      tags: [recogida]
      operationId: recogerLlave
      summary: Canjear el vale por la llave de administrar
      description: |
        Canjea el vale de un solo uso: genera **en ese momento** la llave de administrar de la pool, la
        devuelve **una vez**, y el vale muere. Un vale usado, caducado o que no existe contesta lo mismo:
        `410 RECOGIDA_NO_VALIDA`. Si nunca recogiste tu llave y el vale ya está usado, escríbenos: anulamos lo
        emitido y te mandamos otro enlace.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vale]
              additionalProperties: false
              properties:
                vale: {type: string, minLength: 20, maxLength: 200}
      responses:
        '201':
          description: La llave de administrar. No se volverá a mostrar.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LlaveCreada'}
              examples:
                recogida: {$ref: '#/components/examples/LlaveRecogida'}
        '410': {$ref: '#/components/responses/RecogidaNoValida'}
        '422': {$ref: '#/components/responses/PeticionNoValida'}
        '429': {$ref: '#/components/responses/Ritmo'}
        '500': {$ref: '#/components/responses/Interno'}
      x-codeSamples:
        - lang: shell
          label: curl
          source: |
            # Lo hace la página de recogida; a mano sería:
            curl -sS https://api.munincloud.com/v1/recogida \
              -H "Content-Type: application/json" \
              -d "{\"vale\": \"$VALE_DEL_ENLACE\"}"
components:
  securitySchemes:
    llave:
      type: http
      scheme: bearer
      bearerFormat: mc_live_<id de 12>_<secreto de 43>
      description: |
        `Authorization: Bearer mc_live_<id>_<secreto>`. Dos tipos:
        **administrar** (una por pool, se recoge tras el cobro; gestiona las llaves y consulta la pool) y
        **procesar** (una por empresa; sube y consulta trabajos, consulta la pool). Solo guardamos la huella.
        Cualquier fallo de la llave es 401 con la cabecera `WWW-Authenticate: Bearer`.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Texto libre de 8 a 100 caracteres. Repetido con la misma llave en 24 h, devuelve el mismo trabajo sin descontar otra vez.
      schema: {type: string, minLength: 8, maxLength: 100}
    TrabajoId:
      name: trabajo
      in: path
      required: true
      schema: {type: string, pattern: '^trb_[0-9A-Z]{26}$'}
    LlaveId:
      name: llave
      in: path
      required: true
      description: El id público de la llave (los 12 caracteres tras `mc_live_`), no la llave entera.
      schema: {type: string, pattern: '^[A-Za-z0-9]{12}$'}
  headers:
    RetryAfter:
      description: Segundos que conviene esperar antes de volver a preguntar o reintentar.
      schema: {type: integer, minimum: 1}
    PoolDisponibles:
      description: Procesos que le quedan a la pool este mes (cada respuesta dice lo que queda).
      schema: {type: integer, minimum: 0}
    PoolEstado:
      description: activa, parada o cerrada.
      schema: {type: string, enum: [activa, parada, cerrada]}
  schemas:
    CodigoError:
      type: string
      description: |
        El código estable del error: el que debe mirar tu programa. Los diez primeros son los compartidos del
        proyecto; `NO_ENCONTRADO`, `LLAVE_SIN_PERMISO`, `PETICION_NO_VALIDA` y `RECOGIDA_NO_VALIDA` son
        propios de esta API. La tabla de errores de la documentación los recoge todos. Esta API no devuelve `TOPE_MENSUAL`:
        el tope de 3.600 es de las recargas, que no se compran por aquí.
      enum:
        - LLAVE_INVALIDA
        - LLAVE_ANULADA
        - IP_NO_PERMITIDA
        - POOL_AGOTADA
        - POOL_CERRADA
        - MULTIFACTURA
        - NO_ES_FACTURA
        - DOCUMENTO_NO_VALIDO
        - RITMO_EXCEDIDO
        - ERROR_INTERNO
        - NO_ENCONTRADO
        - LLAVE_SIN_PERMISO
        - PETICION_NO_VALIDA
        - RECOGIDA_NO_VALIDA
    Error:
      type: object
      additionalProperties: false
      required: [codigo, mensaje]
      properties:
        codigo: {$ref: '#/components/schemas/CodigoError'}
        mensaje: {type: string, minLength: 1, description: Para personas. Puede cambiar de redacción; el código no.}
        reintentar_en_s: {type: integer, minimum: 1, description: 'En 429 y 503: lo mismo que Retry-After.'}
        recargar: {type: string, format: uri, description: 'En POOL_AGOTADA: dónde se recarga la pool.'}
        tipo_detectado:
          type: [string, 'null']
          enum: [TICKET, NOMINA, ONBOARDING, bancos, null]
          description: 'En NO_ES_FACTURA, si se reconoció qué es: TICKET, NOMINA, ONBOARDING o bancos. null si no se sabe.'
    RespuestaError:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: {$ref: '#/components/schemas/Error'}
    PoolResumen:
      type: object
      additionalProperties: false
      required: [estado, disponibles, aviso_80]
      properties:
        estado: {type: string, enum: [activa, parada, cerrada]}
        disponibles: {type: integer, minimum: 0}
        aviso_80: {type: boolean, description: Se ha gastado más del 80 % de lo disponible en el mes.}
    Pool:
      type: object
      additionalProperties: false
      required: [estado, periodo, cupo_mes, arrastre, disponibles_mes, usados_mes, disponibles, aviso_80]
      properties:
        estado: {type: string, enum: [activa, parada, cerrada]}
        periodo:
          type: object
          additionalProperties: false
          required: [desde, hasta]
          properties:
            desde: {type: string, format: date}
            hasta: {type: string, format: date}
        cupo_mes: {type: integer, minimum: 0, description: Los procesos que da el plan cada mes.}
        arrastre: {type: integer, minimum: 0, description: 'Lo que pasó del mes anterior: como mucho el 20 % de su cupo.'}
        disponibles_mes: {type: integer, minimum: 0, description: 'Cupo + arrastre (bolsa del plan) + recargas vivas. Ya no tiene techo de 3.600: ese tope es el de las recargas pendientes.'}
        usados_mes: {type: integer, minimum: 0}
        disponibles: {type: integer, minimum: 0, description: disponibles_mes - usados_mes.}
        aviso_80: {type: boolean}
        parada_desde: {type: [string, 'null'], format: date, description: 'Si está parada: desde cuándo. A los 3 meses se cierra.'}
        recargar: {type: [string, 'null'], format: uri}
    Trabajo:
      type: object
      additionalProperties: false
      required: [trabajo, estado, creado]
      properties:
        trabajo: {type: string, pattern: '^trb_[0-9A-Z]{26}$'}
        estado: {type: string, enum: [en_curso, terminado, rechazado]}
        creado: {type: string, format: date-time}
        terminado: {type: [string, 'null'], format: date-time}
        caduca: {type: [string, 'null'], format: date-time, description: Cuándo deja de existir el resultado (24 h tras terminar).}
        documento:
          $ref: 'respuesta.schema.json#/$defs/factura_auditada'
        error: {$ref: '#/components/schemas/Error'}
        pool: {$ref: '#/components/schemas/PoolResumen'}
      allOf:
        - if: {properties: {estado: {const: terminado}}}
          then: {required: [documento, terminado, caduca], not: {required: [error]}}
        - if: {properties: {estado: {const: rechazado}}}
          then: {required: [error, terminado], not: {required: [documento]}}
        - if: {properties: {estado: {const: en_curso}}}
          then: {not: {anyOf: [{required: [documento]}, {required: [error]}]}}
    ListaIps:
      type: array
      maxItems: 20
      uniqueItems: true
      items:
        type: string
        description: Una IPv4/IPv6 o un rango CIDR.
        pattern: '^[0-9A-Fa-f:.]+(/[0-9]{1,3})?$'
    LlaveNueva:
      type: object
      additionalProperties: false
      required: [cif, nombre]
      properties:
        cif:
          type: string
          pattern: '^[A-Z0-9]{9}$'
          description: El NIF de la empresa cuyas facturas subirá esta llave (sin espacios ni guiones). Se comprueba su forma; la AEAT, no.
        nombre:
          type: string
          minLength: 1
          maxLength: 100
          pattern: '^(?!\s)(?!.*\s$)[^\x00-\x1F\x7F-\x9F\u00AD\u061C\u180E\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF]{1,100}$'
          description: |
            Obligatorio desde `v1.3` (en `v1.2` era opcional). El nombre o razón social de la empresa, como
            aparece en sus facturas (p. ej. `TEST SL`). El motor de lectura lo usa para reconocer qué parte de la
            factura es tu empresa: sin él, tu propio nombre podía salir mal en la respuesta. De 1 a 100 caracteres,
            sin saltos de línea ni tabuladores, sin caracteres invisibles de formato (p. ej. U+200B o U+202E) y
            sin espacios al principio ni al final. Se guarda con la llave,
            como el NIF.
        ips_permitidas: {$ref: '#/components/schemas/ListaIps'}
    LlaveCampos:
      type: object
      required: [id, tipo, creada, anulada]
      properties:
        id: {type: string, pattern: '^[A-Za-z0-9]{12}$'}
        tipo: {type: string, enum: [administrar, procesar]}
        cif: {type: [string, 'null'], description: 'El NIF de la empresa (llaves de procesar); null en la de administrar.'}
        nombre: {type: [string, 'null'], maxLength: 100, description: 'Desde v1.2. El nombre de la empresa de la llave de procesar (obligatorio al crearla desde v1.3); null en la de administrar.'}
        ips_permitidas: {$ref: '#/components/schemas/ListaIps'}
        creada: {type: string, format: date-time}
        anulada: {type: [string, 'null'], format: date-time}
        ultimo_uso: {type: [string, 'null'], format: date-time}
    Llave:
      description: Una llave, sin su secreto.
      allOf:
        - $ref: '#/components/schemas/LlaveCampos'
      unevaluatedProperties: false
    LlaveCreada:
      description: Una llave recién creada o recogida, CON su secreto, que se ve esta vez y nunca más.
      allOf:
        - $ref: '#/components/schemas/LlaveCampos'
        - type: object
          required: [llave]
          properties:
            llave:
              type: string
              pattern: '^mc_live_[A-Za-z0-9]{12}_[A-Za-z0-9_-]{43}$'
              description: La llave completa. Se ve esta vez y nunca más.
      unevaluatedProperties: false
  responses:
    LlaveNoValida:
      description: |
        La llave falta, no es válida, está anulada, se usa desde una IP no permitida o no sirve para esta ruta.
        Códigos: LLAVE_INVALIDA, LLAVE_ANULADA, IP_NO_PERMITIDA, LLAVE_SIN_PERMISO.
      headers:
        WWW-Authenticate:
          schema: {type: string}
          description: Bearer realm="api.munincloud.com", error="invalid_token" (o "insufficient_scope").
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            invalida: {$ref: '#/components/examples/ErrLlaveInvalida'}
            anulada: {$ref: '#/components/examples/ErrLlaveAnulada'}
            ip: {$ref: '#/components/examples/ErrIpNoPermitida'}
            sin_permiso: {$ref: '#/components/examples/ErrLlaveSinPermiso'}
    PoolSinSaldo:
      description: La pool no puede procesar. Códigos POOL_AGOTADA (parada, se recarga con las mismas llaves) y POOL_CERRADA.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            agotada: {$ref: '#/components/examples/ErrPoolAgotada'}
            cerrada: {$ref: '#/components/examples/ErrPoolCerrada'}
    PoolCerrada:
      description: La pool está cerrada (POOL_CERRADA).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            cerrada: {$ref: '#/components/examples/ErrPoolCerrada'}
    DocumentoGrande:
      description: El fichero pasa de 15 MB (DOCUMENTO_NO_VALIDO).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            grande: {$ref: '#/components/examples/ErrDocumentoGrande'}
    DocumentoFormato:
      description: El fichero no es PDF, JPEG ni PNG (DOCUMENTO_NO_VALIDO).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            formato: {$ref: '#/components/examples/ErrDocumentoFormato'}
    DocumentoOPeticion:
      description: Falta el fichero o está roto (DOCUMENTO_NO_VALIDO), o la petición está mal formada (PETICION_NO_VALIDA).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            vacio: {$ref: '#/components/examples/ErrDocumentoVacio'}
            peticion: {$ref: '#/components/examples/ErrPeticion'}
    PeticionNoValida:
      description: La petición está mal formada (PETICION_NO_VALIDA).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            peticion: {$ref: '#/components/examples/ErrPeticion'}
    Ritmo:
      description: Demasiadas peticiones (RITMO_EXCEDIDO).
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            ritmo: {$ref: '#/components/examples/ErrRitmo'}
    NoEncontrado:
      description: No existe, es de otra llave o ya caducó; las tres contestan igual (NO_ENCONTRADO).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            no_encontrado: {$ref: '#/components/examples/ErrNoEncontrado'}
    RecogidaNoValida:
      description: El vale está usado, caducado o no existe (RECOGIDA_NO_VALIDA).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            usada: {$ref: '#/components/examples/ErrRecogida'}
    Interno:
      description: Un fallo nuestro (ERROR_INTERNO). Nunca lleva trazas ni rutas internas.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            interno: {$ref: '#/components/examples/ErrInterno'}
    NoDisponible:
      description: El motor de lectura no está disponible ahora mismo (ERROR_INTERNO); reintenta tras Retry-After. No se ha descontado nada.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            no_disponible: {$ref: '#/components/examples/ErrNoDisponible'}
    ResultadoNoDisponible:
      description: El trabajo terminó, pero el motor de lectura no puede devolver el resultado ahora mismo (ERROR_INTERNO). El trabajo sigue guardado; vuelve a consultar tras Retry-After.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema: {$ref: '#/components/schemas/RespuestaError'}
          examples:
            resultado_no_disponible: {$ref: '#/components/examples/ErrResultadoNoDisponible'}
  examples:
    TrabajoEnCurso:
      value:
        trabajo: trb_01J9ZQ3K4T7W2B5N8C6D0F1G2H
        estado: en_curso
        creado: '2026-10-02T16:18:24Z'
        pool: {estado: activa, disponibles: 2412, aviso_80: false}
    TrabajoTerminado:
      summary: Una compra real del banco de pruebas (NIF reservados por la AEAT), tal como la compone la API.
      externalValue: ejemplos/trabajo_terminado.json
    TrabajoNoEsFactura:
      value:
        trabajo: trb_01J9ZQ3K4T7W2B5N8C6D0F1G2J
        estado: rechazado
        creado: '2026-10-02T16:18:39Z'
        terminado: '2026-10-02T16:18:41Z'
        error: {codigo: NO_ES_FACTURA, mensaje: El documento no es una factura (recibida o emitida). Hoy solo se procesan facturas., tipo_detectado: null}
        pool: {estado: activa, disponibles: 2412, aviso_80: false}
    TrabajoMultifactura:
      value:
        trabajo: trb_01J9ZQ3K4T7W2B5N8C6D0F1G2K
        estado: rechazado
        creado: '2026-10-02T16:20:00Z'
        terminado: '2026-10-02T16:20:31Z'
        error: {codigo: MULTIFACTURA, mensaje: 'El documento contiene más de una factura. Un proceso es una factura: súbalas por separado.'}
        pool: {estado: activa, disponibles: 2412, aviso_80: false}
    PoolActiva:
      value:
        estado: activa
        periodo: {desde: '2026-10-01', hasta: '2026-10-31'}
        cupo_mes: 3000
        arrastre: 600
        disponibles_mes: 3600
        usados_mes: 1188
        disponibles: 2412
        aviso_80: false
        parada_desde: null
        recargar: null
    PoolParada:
      value:
        estado: parada
        periodo: {desde: '2026-10-01', hasta: '2026-10-31'}
        cupo_mes: 500
        arrastre: 0
        disponibles_mes: 500
        usados_mes: 500
        disponibles: 0
        aviso_80: true
        parada_desde: '2026-10-21'
        recargar: https://munincloud.com/api/recargar
    ListaLlaves:
      value:
        llaves:
          - {id: a1B2c3D4e5F6, tipo: administrar, cif: null, nombre: null, ips_permitidas: [], creada: '2026-10-02T09:00:00Z', anulada: null, ultimo_uso: '2026-10-02T16:00:00Z'}
          - {id: k7Q2mX9pL4sA, tipo: procesar, cif: B00000018, nombre: TEST SL, ips_permitidas: ['203.0.113.10'], creada: '2026-10-02T09:05:00Z', anulada: null, ultimo_uso: '2026-10-02T16:18:24Z'}
    LlaveProcesar:
      value: {id: k7Q2mX9pL4sA, tipo: procesar, cif: B00000018, nombre: TEST SL, ips_permitidas: ['203.0.113.10'], creada: '2026-10-02T09:05:00Z', anulada: null, ultimo_uso: '2026-10-02T16:18:24Z'}
    LlaveAnulada:
      value: {id: k7Q2mX9pL4sA, tipo: procesar, cif: B00000018, nombre: TEST SL, ips_permitidas: ['203.0.113.10'], creada: '2026-10-02T09:05:00Z', anulada: '2026-10-02T17:00:00Z', ultimo_uso: '2026-10-02T16:18:24Z'}
    LlaveCreada:
      summary: El secreto del ejemplo es ilustrativo (no vale para nada).
      value: {id: k7Q2mX9pL4sA, tipo: procesar, cif: B00000018, nombre: TEST SL, ips_permitidas: ['203.0.113.10'], creada: '2026-10-02T09:05:00Z', anulada: null, ultimo_uso: null, llave: mc_live_k7Q2mX9pL4sA_EJEMPLOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}
    LlaveRecogida:
      summary: El secreto del ejemplo es ilustrativo (no vale para nada).
      value: {id: a1B2c3D4e5F6, tipo: administrar, cif: null, nombre: null, ips_permitidas: [], creada: '2026-10-02T09:00:00Z', anulada: null, ultimo_uso: null, llave: mc_live_a1B2c3D4e5F6_EJEMPLOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}
    ErrLlaveInvalida:
      value: {error: {codigo: LLAVE_INVALIDA, mensaje: 'Falta la llave o no es válida. Envíala como «Authorization: Bearer mc_live_…».'}}
    ErrLlaveAnulada:
      value: {error: {codigo: LLAVE_ANULADA, mensaje: Esta llave está anulada. Usa otra llave de la pool o crea una nueva.}}
    ErrIpNoPermitida:
      value: {error: {codigo: IP_NO_PERMITIDA, mensaje: Esta llave no admite peticiones desde esta IP.}}
    ErrLlaveSinPermiso:
      value: {error: {codigo: LLAVE_SIN_PERMISO, mensaje: Esta llave no sirve para esta ruta (hace falta la de administrar o la de procesar).}}
    ErrPoolAgotada:
      value: {error: {codigo: POOL_AGOTADA, mensaje: 'La pool está parada porque se han agotado el plan del mes y las recargas. Al recargarla, las mismas llaves vuelven a funcionar.', recargar: 'https://munincloud.com/api/recargar'}}
    ErrPoolCerrada:
      value: {error: {codigo: POOL_CERRADA, mensaje: La pool está cerrada y sus llaves anuladas. Hace falta una pool nueva.}}
    ErrRitmo:
      value: {error: {codigo: RITMO_EXCEDIDO, mensaje: Demasiadas peticiones seguidas. Espera y reintenta., reintentar_en_s: 5}}
    ErrDocumentoGrande:
      value: {error: {codigo: DOCUMENTO_NO_VALIDO, mensaje: El fichero pasa de 15 MB.}}
    ErrDocumentoFormato:
      value: {error: {codigo: DOCUMENTO_NO_VALIDO, mensaje: 'El fichero no es PDF, JPEG ni PNG.'}}
    ErrDocumentoVacio:
      value: {error: {codigo: DOCUMENTO_NO_VALIDO, mensaje: Falta el campo «factura» o el fichero está vacío o roto.}}
    ErrPeticion:
      value: {error: {codigo: PETICION_NO_VALIDA, mensaje: 'El campo «cif» no tiene forma de NIF (9 caracteres, letras y números).'}}
    ErrNoEncontrado:
      value: {error: {codigo: NO_ENCONTRADO, mensaje: 'No existe, no es de esta llave o ya caducó (los resultados se guardan 24 horas).'}}
    ErrRecogida:
      value: {error: {codigo: RECOGIDA_NO_VALIDA, mensaje: 'Este enlace ya se ha usado, ha caducado o no existe. Si no recogiste tu llave, escríbenos.'}}
    ErrInterno:
      value: {error: {codigo: ERROR_INTERNO, mensaje: 'No se pudo completar la petición por un fallo nuestro. No se ha descontado nada; puedes reintentar.'}}
    ErrNoDisponible:
      value: {error: {codigo: ERROR_INTERNO, mensaje: El motor de lectura no está disponible ahora mismo. No se ha descontado nada., reintentar_en_s: 30}}
    ErrResultadoNoDisponible:
      value: {error: {codigo: ERROR_INTERNO, mensaje: 'El resultado no se puede recoger ahora mismo. El trabajo sigue guardado: vuelve a consultarlo en unos segundos.', reintentar_en_s: 30}}
