openapi: 3.1.0

# El contrato de cable de la API pública de Facta.
#
# Esto es la **T1b** que pide `docs/plan/tareas/api-publica.md` §18: el plan
# nombraba quince rutas con una línea cada una y medía, sobre sí mismo, que le
# faltaban los tipos de contenido, los códigos de estado, los nombres de
# cabecera, la taxonomía de errores y la semántica de `Idempotency-Key`. Todo
# eso está aquí, y está escrito DESDE el servidor que ya emite con sello real,
# no antes: cada decisión de abajo salió de implementarla o de que el
# Ministerio de Hacienda la corrigiera en vivo.
#
# ALCANCE — lo que describe es el ESQUELETO, no la API entera del plan. Faltan
# a propósito: `/v1/dte/submit` (modo BYOK), `/v1/notes`, `/v1/dte/{cg}/invalidate`,
# `/v1/destinations`, `/v1/dte/{cg}/copies`, `/v1/dte/pending-copies`,
# `/v1/dte/{cg}/render`, `/v1/webhooks` y el listado paginado `GET /v1/dte`.
# Cuando existan, se añaden aquí y el número de versión sube.
#
# LAS SEIS DECISIONES DE CABLE, dichas una vez:
#
#   1. La llave viaja en `X-Facta-Key`, NUNCA en `Authorization`. La API vive
#      detrás del gateway de Supabase, que lee `Authorization` por su cuenta;
#      meter ahí una llave opaca es pedirle a dos sistemas que interpreten el
#      mismo encabezado.
#   2. Todo cuerpo es `application/json; charset=utf-8`, y toda respuesta
#      también — incluidos los errores.
#   3. Un error SIEMPRE es `{ "error": { "code", "message", "details" } }`.
#      `code` es el contrato y no cambia; `message` es español para humanos y
#      puede reescribirse. Un cliente que haga `switch` sobre `message` está mal
#      escrito.
#   4. `Idempotency-Key` es OBLIGATORIA en las tres rutas que gastan
#      correlativo. Alcance `(llave, Idempotency-Key)`, TTL 24 h, y reusar la
#      misma llave con otro cuerpo es 422 y no una repetición silenciosa.
#   5. Un rechazo del MH es **422**, no 502: Hacienda LEYÓ el documento y lo
#      negó, así que es un problema de los datos de quien llama. Y la respuesta
#      nombra el correlativo que se gastó, porque el §167 permite corregir con
#      ESE mismo número.
#   6. El emisor, el ambiente, el establecimiento y el punto de venta salen de
#      la base a partir de la llave. El cliente no los puede nombrar, ni
#      equivocarse en ellos, ni mentirlos.
#
# REVISADO CONTRA EL CÓDIGO el 8-sep-2026, ruta por ruta y campo por campo
# (`docs/plan/tareas/api-documentacion-y-mejoras.md` §2). Lo que esa revisión
# corrigió aquí: el `almacenamiento` que faltaba en la respuesta de
# contingencia, el alcance que exige cada ruta, el techo de 1 MB del cuerpo,
# las respuestas 404/405 que ninguna operación declaraba, y que `mh_unreachable`
# (502) hoy NO se devuelve nunca — todo fallo de transporte sale como 202.

info:
  title: Facta — API de facturación electrónica (DTE) de El Salvador
  version: 0.1.0-esqueleto
  summary: Emite documentos tributarios electrónicos sellados por el Ministerio de Hacienda.
  description: |
    Una llamada HTTP con una llave de API produce un DTE firmado y sellado por
    el MH. El cliente describe la venta —quién, qué, cuánto cada cosa— y el
    servidor hace todo lo fiscal: correlativo transaccional, totales, IVA,
    monto en letras, firma JWS RS512 y transmisión.

    **El cliente nunca calcula dinero y nunca firma.** Un SDK que calculara IVA
    sería un segundo motor fiscal, y dos motores se desincronizan el primer
    martes (`docs/plan/tareas/sdks-de-la-api.md` §5).

    **La llave decide el ambiente, y tiene que coincidir con el de su empresa.**
    Una llave `facta_test_` emite en pruebas (00) contra `apitest`; una
    `facta_live_` emite documentos fiscales reales (01). Las dos usan la MISMA
    URL base: lo que cambia es la llave.

    Una llave de producción solo se puede acuñar para una empresa que YA pasó su
    propia ceremonia de paso a producción —la que puso su certificado y su
    credencial de transmisión de verdad—, y el servidor compara los dos
    ambientes antes de firmar nada. Cruzarlos es `403 environment_not_allowed`:
    una llave de pruebas no emite un documento real, y una de producción no
    ensucia el ambiente de pruebas.

    **La ruta decide, y el método se comprueba después.** Una ruta que existe
    con el método equivocado responde `405 method_not_allowed`; una ruta que no
    existe, `404 not_found`. Ese orden no es un detalle de implementación: si
    decidiera el método, un `POST` a una ruta de consulta caería en el emisor y
    quemaría un correlativo.

    **Cada ruta exige un alcance de la llave**, y la llave los lleva escritos:
    `issue` para las tres que emiten, `query` para la consulta, `download` para
    el área de retención. `/v1/status` no exige ninguno. Un alcance que falta es
    `403 forbidden_scope`, y `details` dice cuál hacía falta y cuáles tiene.

    **El cuerpo no puede pasar de 1 MB** (`400 invalid_request`). Dos mil
    líneas de detalle caben de sobra; un archivo adjunto no es asunto de esta
    API.

    **Toda respuesta dice cuánto cupo queda**, en `RateLimit-Limit`,
    `RateLimit-Remaining` y `RateLimit-Reset`, más `RateLimit-Policy` con las
    dos ventanas. Los números describen la que está MÁS cerca de morder: con
    5 llamadas libres en la hora y 1 en el día, lo que se anuncia es 1 — decir
    5 sería invitar a un 429 en la segunda. Y cuando toca esperar —429, 409 en
    vuelo, 503— la respuesta trae además `Retry-After` en segundos.

    **Consultar el estado no gasta el techo que reporta.** `/v1/status` se
    cuenta en una ventana propia y generosa (240 por hora de fábrica), porque
    un monitor que pregunta cada minuto se comía el cupo de emitir. Sigue
    contándose: una ruta sin medir es un martillo gratis para una llave
    robada.
  contact:
    name: Facta
    url: https://factadte.com
  license:
    name: Propietaria

servers:
  - url: https://hcnvknpsbadplnfcflxx.supabase.co/functions/v1/api-v1
    description: |
      URL pública única de Facta. La llave selecciona el ambiente del
      Ministerio de Hacienda: `facta_test_` transmite a `apitest.dtes.mh.gob.sv`
      y `facta_live_` al servicio de producción. Las pruebas se ejecutan en
      esta misma URL con el certificado y la llave de pruebas; no se publica
      una segunda URL de staging.

      El prefijo `/functions/v1/api-v1` se normaliza en el servidor, así que
      `…/functions/v1/api-v1/v1/dte` y `…/v1/dte` llegan a la misma ruta. Los
      caminos de abajo son los que se escriben DESPUÉS de esta URL base.

security:
  - FactaApiKey: []
  # `X-Facta-Sign-Key` NO va aquí: no es un esquema de seguridad global, es un
  # segundo factor que solo piden las rutas que firman. Declararlo global haría
  # creer a un generador de clientes que `/v1/status` lo necesita — y la mitad
  # del diseño de §6 es justamente que consultar NO lo necesita.

tags:
  - name: Emisión
    description: Las rutas que gastan un correlativo. Todas exigen `Idempotency-Key`.
  - name: Consulta
    description: Leer lo emitido. No gastan nada.
  - name: Salud
    description: Estado de la llave y techos restantes.
  - name: Almacenamiento
    description: |
      El área de retención (`api-almacenamiento-y-contingencia.md` §4/§5): una
      copia cifrada de una hora del documento firmado, para el caso en que la
      escritura a TU destino real falle. Exige el alcance `download`, además
      de `X-Facta-Key`.

paths:
  /v1/openapi.json:
    get:
      tags: [Salud]
      operationId: contrato
      summary: El contrato de esta API, servido por ella misma
      description: |
        **Alcance: ninguno — y tampoco hace falta llave.**

        Es la única ruta que no autentica, y la razón es el orden de las cosas:
        lo primero que necesita quien va a integrar es saber qué hay, y eso
        ocurre antes de tener una llave. No gasta cupo, no deja fila en la
        bitácora y se puede leer desde un navegador (`Access-Control-Allow-Origin: *`).

        Son los mismos bytes que sirve `/api/openapi.json` en el sitio, salidos
        del mismo archivo. Si el sitio estuviera caído, la API sigue sabiendo
        describirse.
      security: []
      responses:
        "200":
          description: Este mismo documento, en JSON.
          headers:
            Cache-Control: { schema: { type: string }, description: "`public, max-age=3600`." }
          content:
            application/json:
              schema: { type: object, description: Un documento OpenAPI 3.1. }
        "405":
          description: Solo GET (y HEAD).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/status:
    get:
      tags: [Salud]
      operationId: status
      summary: Salud, ambiente y techos restantes
      description: |
        **Alcance: ninguno.** Cualquier llave viva contesta aquí.

        Lo primero que llama un integrador. Confirma que la llave sirve, dice
        para qué empresa emite, en qué ambiente y cuánto cupo le queda.

        Ojo: la petición se cuenta a sí misma. Llamar a `/v1/status` gasta una
        unidad de los techos por hora y por día, porque el contador se escribe
        ANTES de trabajar (ver `Techos`).
      responses:
        "200":
          description: La llave es válida.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Status" }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/Techo" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte:
    post:
      tags: [Emisión]
      operationId: emitirDte
      summary: Emitir un DTE en una sola llamada
      description: |
        **Alcance: `issue`.** Y `X-Facta-Sign-Key`, que es el segundo factor.

        `prepare` + `sign` en un solo viaje. Es la ruta normal: reserva el
        correlativo, construye y valida contra el esquema oficial, firma con el
        certificado del emisor, transmite al MH y escribe la fila de índice.

        **El orden importa y no es negociable**: se construye, se valida y se
        comprueba el techo de monto ANTES de reservar. Nada por debajo de la
        reserva se puede devolver.

        Devuelve el JWS firmado además del documento: es lo que el cliente
        archiva, porque volver a serializar el JSON después no reproduce los
        mismos bytes que Hacienda validó.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/SignKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SolicitudDte" }
            examples:
              creditoFiscalPorClienteGuardado:
                summary: CCF a un cliente que ya vive en Facta
                value:
                  tipoDte: "03"
                  receptor: { customerId: "374114b6-e957-4c7a-8911-dd6381b1e0ea" }
                  items:
                    - descripcion: "Integración de la API de facturación electrónica"
                      cantidad: 1
                      precioUni: 25
              facturaAConsumidorFinal:
                summary: FE sin receptor (consumidor final anónimo)
                value:
                  tipoDte: "01"
                  items:
                    - descripcion: "Café"
                      cantidad: 2
                      precioUni: 1.5
      responses:
        "200":
          description: |
            Sellado por el MH. `Idempotency-Replayed: true` si esta respuesta ya
            estaba guardada de un intento anterior con la misma llave.
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DteSellado" }
        "202":
          description: |
            El documento quedó FIRMADO pero el MH no respondió. No es un fallo
            que se reintente: los bytes existen y se le deben a Hacienda. La
            reserva queda retenida en contingencia y se retransmite después; los
            plazos (24 h / 72 h) son de la ley, no nuestros.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DteEnContingencia" }
        "400":
          description: |
            Cuerpo mal formado, ausente, de más de 1 MB, o falta
            `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "404":
          description: El `customerId` no existe en la empresa de la llave.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409": { $ref: "#/components/responses/EnVueloOSinVault" }
        "422":
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          description: |
            El documento no pasó — y hay dos razones muy distintas:

            · `validation_failed`: no cumple el esquema oficial. **No se gastó
              correlativo**; `details.issues` dice qué campo.
            · `mh_rejected`: Hacienda lo leyó y lo negó. **Sí se gastó**, y
              `details` nombra `codigoGeneracion` y `numeroControl` para que la
              corrección los reuse (§167).
            · `idempotency_key_reuse`: la misma llave con otro cuerpo.
            · `no_storage_destination`: la empresa no tiene ningún destino de
              almacenamiento conectado y verificado en los últimos 30 días.
              **No se gastó correlativo** — es la puerta de
              `api-almacenamiento-y-contingencia.md` §2.1, y corre antes que
              nada más: firmar sin un sitio donde el documento pueda aterrizar
              deja al cliente sin ninguna copia salvo la de esta misma
              respuesta.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                rechazoDelMh:
                  value:
                    error:
                      code: mh_rejected
                      message: "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE"
                      details:
                        estado: RECHAZADO
                        descripcionMsg: "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE"
                        observaciones: []
                        codigoGeneracion: E4311553-DADF-4168-829E-B6B7E50F1D41
                        numeroControl: DTE-03-M001P001-000000000000177
        "429": { $ref: "#/components/responses/Techo" }
        "500": { $ref: "#/components/responses/ErrorInterno" }
        "502": { $ref: "#/components/responses/MhCaido" }
        "503": { $ref: "#/components/responses/NoDisponible" }

    get:
      tags: [Consulta]
      operationId: listarDte
      summary: Listar lo emitido, por rango de fechas
      description: |
        **Alcance: `query`.**

        La pregunta que hace un sistema que se cayó y volvió: qué alcancé a
        emitir. Devuelve el libro de lo sellado (`dte_index`) de la empresa de
        la llave, del más nuevo al más viejo.

        **La paginación es por cursor, no por página.** `?pagina=2` sobre una
        tabla que crece repite un documento y se salta otro, porque entre las
        dos llamadas entraron filas. El `siguiente` que devuelve esta ruta es la
        posición exacta de la última fila entregada; páselo tal cual y la
        frontera no se mueve pase lo que pase. Es opaco a propósito: quien lo
        desarme se rompe el día que cambie el orden.

        **Un documento RECHAZADO no está aquí**, y no es un olvido: un rechazo
        no es un documento fiscal y su fila dejaría el `numeroControl` retenido
        para siempre. Si al reconciliar aparece un hueco en su numeración,
        pregunte por ese código con `GET /v1/dte/{codigoGeneracion}`, que sí
        contesta por los rechazados.
      parameters:
        - name: desde
          in: query
          schema: { type: string, format: date }
          description: Fecha de emisión, inclusive.
          example: "2026-09-01"
        - name: hasta
          in: query
          schema: { type: string, format: date }
          description: Fecha de emisión, inclusive.
        - name: estado
          in: query
          schema: { type: string, enum: [contingencia, firmado, invalidado, rechazado, sellado] }
          description: En español, el mismo vocabulario que devuelve la consulta.
        - name: tipoDte
          in: query
          schema: { type: string, pattern: "^\\d{2}$" }
          example: "03"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          description: 'Pedir más de 100 no es un error: se sirven 100.'
        - name: cursor
          in: query
          schema: { type: string }
          description: El `siguiente` de la página anterior, tal cual.
      responses:
        "200":
          description: La página, de la más nueva a la más vieja.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginaDeDocumentos" }
        "400":
          description: Una fecha mal escrita, un estado que no existe, un cursor inventado.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/Techo" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/prepare:
    post:
      tags: [Emisión]
      operationId: prepararDte
      summary: Reservar el correlativo y obtener el documento canónico
      description: |
        **Alcance: `issue`.** Aquí NO se abre el vault de firma, así que esta
        ruta no pide `X-Facta-Sign-Key`: reservar y construir es todo lo que un
        token robado puede hacer, y firmar no está entre esas dos cosas.

        Para quien quiere ver el documento —y sus totales— antes de firmarlo.

        **Reserva el correlativo**, así que un `prepare` sin su `sign` deja un
        número entregado. Eso es un hueco que alguien tiene que explicar, y por
        eso `prepareToken` vence a los 15 minutos.

        `prepareToken` no es una credencial: sola no firma nada. Es un MAC sobre
        el hash canónico del documento, y su único trabajo es garantizar que lo
        que se firma es lo que obtuvo ese número. El hash se calcula sobre el
        JSON con las claves ORDENADAS, así que un cliente que reordene el objeto
        al deserializarlo (PHP, Go, Python viejo) sigue verificando.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SolicitudDte" }
      responses:
        "200":
          description: Correlativo reservado; el documento todavía no está firmado.
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DtePreparado" }
        "400": { description: "Cuerpo mal formado, de más de 1 MB, o falta `Idempotency-Key`.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "409": { $ref: "#/components/responses/EnVuelo" }
        "422": { description: "`validation_failed` o `no_storage_destination` — ninguno gasta correlativo.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/Techo" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/sign:
    post:
      tags: [Emisión]
      operationId: firmarDte
      summary: Firmar y transmitir un documento preparado
      description: |
        **Alcance: `issue`.** Y `X-Facta-Sign-Key`.

        Firma con el certificado del emisor y transmite al MH. El cuerpo
        devuelve el documento tal cual salió de `prepare`, junto con su
        `prepareToken`.

        Si el documento cambió aunque sea un centavo, la respuesta es 422
        `prepare_token_invalid` y no se firma nada.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/SignKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SolicitudFirma" }
      responses:
        "200":
          description: Sellado por el MH.
          headers:
            Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DteSellado" }
        "202":
          description: Firmado sin respuesta del MH; queda en contingencia.
          content: { application/json: { schema: { $ref: "#/components/schemas/DteEnContingencia" } } }
        "400": { description: "Cuerpo mal formado, de más de 1 MB, o falta `Idempotency-Key`.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "409": { $ref: "#/components/responses/EnVueloOSinVault" }
        "422":
          description: "`prepare_token_invalid` (vencido, de otra llave, o el documento cambió) o `mh_rejected`."
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/Techo" }
        "502": { $ref: "#/components/responses/MhCaido" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/{codigoGeneracion}:
    get:
      tags: [Consulta]
      operationId: consultarDte
      summary: Consultar un documento por su código de generación
      description: |
        **Alcance: `query`.**

        Responde para un documento sellado **y también para uno rechazado**, que
        no tiene fila de índice pero sí reserva. Devolver 404 para un número que
        Hacienda negó mandaría al integrador a buscar un bug que no existe.
      parameters:
        - name: codigoGeneracion
          in: path
          required: true
          schema: { type: string, format: uuid }
          example: 7875BC7A-9580-441D-94E4-FA455E9D8BD0
      responses:
        "200":
          description: El documento, en el estado en que esté.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DteConsultado" }
        "400": { description: El código no tiene forma de UUID., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "404": { description: No existe en la empresa de la llave., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/Techo" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/{codigoGeneracion}/invalidate:
    post:
      tags: [Emisión]
      operationId: anularDte
      summary: Anular un documento que Hacienda ya selló
      description: |
        **Alcance: `issue`.** Y `X-Facta-Sign-Key`: la anulación se firma con el
        certificado del emisor, igual que el documento que anula.

        **Una anulación no es un borrado.** Es un EVENTO: su propio documento
        firmado, con su propio código de generación y su propio sello, enviado a
        otro servicio del Ministerio. El documento anulado no desaparece de
        ningún registro — cambia de estado, y esta ruta devuelve el sello que lo
        prueba. No hay forma de deshacerla.

        **No gasta correlativo, y aun así exige `Idempotency-Key`**: un reintento
        por timeout mandaría un segundo evento contra un documento que el
        primero ya anuló.

        **Los tres tipos no son intercambiables** (CAT-024):

        · `1` error en el documento — lo anula JUNTO CON el que lo reemplaza,
          así que `codigoGeneracionReemplazo` es obligatorio, y `motivo` también.
          Emita primero el documento correcto.
        · `2` rescisión de la operación — no nombra reemplazo, y nombrarlo es un
          error (el campo 110 tiene que ir vacío).
        · `3` otro — como el 1: reemplazo y motivo.

        Solo se anula un documento **con sello**. Un rechazo nunca existió para
        Hacienda, y su correlativo lo reutiliza el documento corregido (§167).

        Pedir la anulación de algo **ya anulado** contesta 200 con
        `yaEstabaInvalidado: true`. Un reintento que recibe un error enseña a
        hacer algo peor.
      parameters:
        - name: codigoGeneracion
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/SignKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SolicitudAnulacion" }
            examples:
              rescision:
                summary: El cliente desistió de la compra
                value:
                  tipoAnulacion: 2
                  responsable: { nombre: "Ana Rivas", tipoDocumento: "36", numDocumento: "06142803901121" }
                  solicita: { nombre: "Ana Rivas", tipoDocumento: "36", numDocumento: "06142803901121" }
              errorConReemplazo:
                summary: Salió con el monto equivocado y ya se emitió el correcto
                value:
                  tipoAnulacion: 1
                  motivo: "El precio unitario iba sin el descuento pactado"
                  codigoGeneracionReemplazo: "7875BC7A-9580-441D-94E4-FA455E9D8BD0"
                  responsable: { nombre: "Ana Rivas", tipoDocumento: "36", numDocumento: "06142803901121" }
                  solicita: { nombre: "Beto Cruz", tipoDocumento: "13", numDocumento: "012345678" }
      responses:
        "200":
          description: Anulado, con el sello del evento.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DteAnulado" }
        "400": { description: "Cuerpo mal formado, o falta `Idempotency-Key`.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "404": { description: No existe ese documento en la empresa de la llave., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { $ref: "#/components/responses/EnVueloOSinVault" }
        "422":
          description: |
            `validation_failed` (el documento no tiene sello, o el evento no
            cumple el esquema) o `mh_rejected` (Hacienda leyó la anulación y la
            negó — el plazo vencido es la razón más común).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/Techo" }
        "502":
          description: |
            `mh_unreachable` — no llegamos a Hacienda, así que **no se anuló
            nada** y el documento sigue vivo. Reintente con la MISMA
            `Idempotency-Key`.

            Aquí sí ocurre, a diferencia de la emisión: un DTE firmado existe y
            se le debe a Hacienda, pero un evento que no llegó no anuló nada.
          headers:
            Retry-After: { $ref: "#/components/headers/RetryAfter" }
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/{codigoGeneracion}/file:
    get:
      tags: [Almacenamiento]
      operationId: descargarDocumentoRetenido
      summary: Bajar el JSON firmado (o el PDF) del área de retención
      description: |
        **Alcance: `download`.**

        El área de retención dura **una hora** desde que el documento se
        firmó — nunca es el archivo fiscal de nadie, es la red para cuando su
        propia escritura al destino falla. **Pasada esa hora esta ruta sigue
        contestando**: el documento se rearma desde la reserva, que guarda el
        JWS sellado. El JSON son los mismos bytes; la hoja se vuelve a dibujar
        con el sello que ya está en el índice.

        Lo que NO puede rearmar son los documentos emitidos desde la app: esos
        viajan cifrados con una llave que vive en un navegador y que ningún
        servidor tiene. La respuesta entonces es la misma que si no existieran. Los bytes que devuelve son
        EXACTAMENTE los que se cifraron al guardarlos: un JSON con
        `codigoGeneracion`, `ambiente` y el `jws` compacto (el mismo artefacto
        que ya recibiste en la respuesta de `/v1/dte/sign` o `/v1/dte`), o el
        PDF una vez que la representación gráfica exista.

        La ruta al bucket la deriva el servidor de la fila — company_id y
        environment salen de TU llave, nunca del cuerpo ni de la URL — así
        que no hay forma de pedir el documento de otra empresa nombrando otro
        `codigoGeneracion`: si no es tuyo, la respuesta es la MISMA que si no
        existiera (ver `NoEnElArea`).
      security:
        - FactaApiKey: []
      parameters:
        - name: codigoGeneracion
          in: path
          required: true
          schema: { type: string, format: uuid }
          example: 7875BC7A-9580-441D-94E4-FA455E9D8BD0
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [json, pdf], default: json }
      responses:
        "200":
          description: Los bytes decifrados, tal como se guardaron.
          headers:
            Content-Disposition: { schema: { type: string }, description: 'attachment; filename="<codigoGeneracion>.<kind>"' }
          content:
            application/json:
              schema: { type: object, properties: { codigoGeneracion: { type: string, format: uuid }, ambiente: { type: string, enum: ["00", "01"] }, jws: { type: string } } }
            application/pdf:
              schema: { type: string, format: binary }
        "400": { description: El código no tiene forma de UUID., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "404": { $ref: "#/components/responses/NoEnElArea" }
        "429": { $ref: "#/components/responses/Techo" }
        "503": { $ref: "#/components/responses/NoDisponible" }

  /v1/dte/holding:
    get:
      tags: [Almacenamiento]
      operationId: listarAreaDeRetencion
      summary: Qué hay en tu área de retención ahora mismo
      description: |
        **Alcance: `download`.**

        Paginado, y **sin ningún filtro de empresa aceptado**: la única
        empresa posible es la de la llave. No devuelve rutas de bucket ni
        contenido — solo evidencia de estado (`whereLanded`, intentos,
        cuándo vence, cuántas veces se descargó).
      security:
        - FactaApiKey: []
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: La lista, más reciente primero.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documentos:
                    type: array
                    items: { $ref: "#/components/schemas/DocumentoRetenido" }
        "401": { $ref: "#/components/responses/NoAutorizado" }
        "403": { $ref: "#/components/responses/Prohibido" }
        "429": { $ref: "#/components/responses/Techo" }

components:
  securitySchemes:
    FactaApiKey:
      type: apiKey
      in: header
      name: X-Facta-Key
      description: |
        `facta_test_<id>.<secreto>` en pruebas, `facta_live_<id>.<secreto>` en
        producción. La parte antes del punto es pública e identifica la fila; la
        de después se muestra UNA vez al acuñarla y no se guarda (solo su
        SHA-256 con pimienta).

        El prefijo y el ambiente de la fila tienen que coincidir: una llave
        `facta_test_` que apuntara a una fila de producción es 403, jamás una
        emisión.

        **Nunca en `Authorization`.** El gateway lee esa cabecera por su cuenta.

        **Esta llave sola no firma.** Abre nada: identifica y autentica. Para
        firmar hace falta además `X-Facta-Sign-Key` (ver los dos vaults del
        plan, §6).

        **Lista de IP autorizadas** (opcional, se define al acuñar la llave):
        cada entrada es una dirección —`190.53.1.2`, `2001:db8::1`— o un rango
        en notación CIDR —`190.53.1.0/24`, `2001:db8::/32`—, IPv4 o IPv6. Un
        integrador detrás de un NAT con varias salidas pone su prefijo, no cada
        dirección. La lista vacía significa «desde cualquier parte». Si la llave
        tiene lista y tu IP no está en ninguna de sus entradas, la respuesta es
        `ip_not_allowed` (403) antes de cualquier trabajo. La IP que se compara
        es la del extremo que nos conecta, y una cabecera `X-Forwarded-For`
        puesta por el cliente no la cambia.

  parameters:
    SignKey:
      name: X-Facta-Sign-Key
      in: header
      required: true
      schema: { type: string, pattern: "^factask_[A-Za-z0-9_-]{43}$" }
      description: |
        La contraseña que abre **el vault de firma**, donde vive el certificado
        del emisor. 256 bits generados por el navegador en la ceremonia de
        acuñado y mostrados UNA vez.

        Por qué existe: en la web hay dos secretos independientes (la sesión y
        la llave del vault, que nunca viaja) y robar uno no basta. En una API
        hay un solo token, y si ese token bastara para firmar, un `.env`
        filtrado sería poder de emitir DTE reales con el certificado del
        cliente. Con esta separación, **el token robado no firma**.

        Qué hacemos con ella: se convierte a bytes al entrar, se deriva la KEK
        por HKDF-SHA-256, se abre la envoltura y el arreglo se pone a cero. No
        se guarda, no se registra en ninguna bitácora y no hay hash de ella en
        nuestra base — el verificador es la propia etiqueta GCM de la
        envoltura. Lo que sí guardamos es el CONTEO de intentos fallidos: cinco
        seguidos en una hora suspenden la llave.

        **No es `FACTA_UNLOCK_KEY`.** Esa otra (prefijo `factauk_`) abre sus
        credenciales de almacenamiento en SU servidor y no viaja nunca a Facta.
        Si la manda aquí, la respuesta se lo dice en vez de intentarlo.

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 200 }
      example: venta-2026-09-02-00417
      description: |
        Identificador que el CLIENTE elige para esta operación. Obligatorio en
        toda ruta que no se pueda deshacer: las tres que gastan correlativo y la
        anulación. Sin él, un reintento por timeout quemaría un segundo número —
        o mandaría un segundo evento contra un documento que el primero ya
        anuló.

        Reglas, todas verificables:

        · **Alcance** `(llave, Idempotency-Key)`. Dos integradores pueden usar
          la misma cadena sin chocar, y ninguno puede leer la respuesta del otro.
        · **Repetir la misma llave con el MISMO cuerpo** devuelve la respuesta
          guardada, con `Idempotency-Replayed: true`. Eso incluye un rechazo del
          MH: el reintento devuelve el mismo 422, sin quemar otro número.
        · **Repetir con OTRO cuerpo** es 422 `idempotency_key_reuse`. Devolver
          la factura de ayer para la venta de hoy es peor que fallar.
        · **Mientras la primera está en vuelo**, la segunda es 409
          `idempotency_in_flight` — no se encola. El cliente reintenta.
        · **TTL 24 h.** Pasado eso la cadena queda libre: un reintento un día
          después es una venta nueva, no un reintento.

  headers:
    RateLimitLimit:
      schema: { type: integer }
      description: El techo de la ventana más cercana a morder.
    RateLimitRemaining:
      schema: { type: integer }
      description: Lo que queda de ESA ventana, con esta petición ya descontada.
    RateLimitReset:
      schema: { type: integer }
      description: |
        Segundos hasta que esa ventana se libera del todo. Es la longitud
        entera de la ventana deslizante (3600 u 86400), no un instante
        calculado: el momento exacto en que se libera un turno es cuando la
        petición más vieja de la ventana cumple una hora, y saberlo costaría
        otra consulta. Redondear hacia arriba nunca dice «vuelva» antes de
        tiempo.
    RateLimitPolicy:
      schema: { type: string }
      description: Las dos ventanas, `hour;q=60;w=3600, day;q=300;w=86400`.
    RetryAfter:
      schema: { type: integer }
      description: Segundos que conviene esperar. Solo en los errores donde esperar arregla algo.
    IdempotencyReplayed:
      schema: { type: boolean }
      description: |
        Presente y `true` cuando la respuesta salió de la caché de idempotencia.

        Sale también sobre un **error** guardado: un rechazo del MH se repite
        con su mismo 422 y su mismo correlativo gastado, que es justo lo que
        evita que el reintento queme un segundo número.

  responses:
    NoAutorizado:
      description: |
        `unauthorized` (no vino llave) o `invalid_api_key` (no sirve). Las dos
        tardan lo mismo, a propósito.

        En las rutas que firman se suman las dos del segundo factor:
        `sign_key_required` (falta `X-Facta-Sign-Key`, o vino la de apertura por
        error) y `sign_key_invalid` (no abre el vault). `details.intentosRestantes`
        dice cuántos quedan antes de que la llave se suspenda sola.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Prohibido:
      description: |
        La llave existe pero no puede: `key_revoked`, `key_expired`,
        `key_inactive`, `forbidden_scope`, `dte_type_not_allowed`,
        `ip_not_allowed`, `environment_not_allowed` o `sign_vault_locked`
        (demasiados intentos fallidos de abrir el vault: esperar no lo arregla,
        hay que reactivar la llave desde la app).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    EnVuelo:
      description: |
        `idempotency_in_flight` — la primera petición con esa llave todavía
        trabaja. `details.enVueloSegundos` dice desde cuándo.

        **Si esa primera petición ya había empezado algo que no se deshace**
        —un correlativo reservado, o una anulación camino de Hacienda— el
        mensaje NOMBRA el documento (`details.codigoGeneracion`) para que se
        pueda preguntar `GET /v1/dte/{codigoGeneracion}` qué fue de él, en vez
        de esperar las 24 h del reclamo. Si no había llegado a eso, no pasó nada
        irreversible y el barrido de cinco minutos suelta el reclamo solo a la
        media hora (`details.seLiberaSola`).
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    EnVueloOSinVault:
      description: |
        `idempotency_in_flight` (la primera petición con esa llave todavía
        trabaja — reintenta) o `sign_vault_missing` (la llave no está
        provisionada para firmar — reintentar no la arregla, hay que acuñarla
        de nuevo desde la app). El `code` los distingue.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Techo:
      description: |
        `rate_limited` (por hora, por día, o la ventana propia de `/v1/status`)
        o `amount_limit` (el documento pasa del monto máximo de la llave). El de
        monto se comprueba ANTES de reservar: no gasta correlativo.

        `details.window` dice cuál de las tres fue.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    MhCaido:
      description: |
        `mh_unreachable` — no llegamos a Hacienda. Nadie juzgó nada.

        **RESERVADO: la versión actual nunca lo devuelve.** Todo fallo de
        transporte al MH —caída, timeout, respuesta ilegible, «PROCESADO» sin
        sello— sale como **202 contingencia**, porque para entonces el documento
        YA está firmado y se le debe a Hacienda; un 502 invitaría a reintentar y
        el reintento firmaría un segundo documento. El código se queda en la
        taxonomía para el día en que exista un fallo ANTES de firmar que sí se
        pueda reintentar.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NoDisponible:
      description: |
        `service_unavailable` o `correlative_unavailable` — algo nuestro no está
        listo. Puede salir en CUALQUIER ruta, incluidas las de consulta: el
        registro de peticiones se escribe antes de trabajar y falla cerrado, así
        que si esa tabla no contesta, nada contesta.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    ErrorInterno:
      description: "`internal_error`. El mensaje nunca describe nuestras entrañas."
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NoEnElArea:
      description: |
        `not_found` — igual para "ese documento no existe en el área de
        retención" que para "existe, pero es de otra empresa o de otro
        ambiente". **Nunca 403**: distinguir los dos casos confirmaría
        códigos de generación ajenos, y el código va impreso en cada factura.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Estable. Es sobre esto que un cliente decide qué hacer.
              enum:
                - unauthorized
                - invalid_api_key
                - key_revoked
                - key_expired
                - key_inactive
                - forbidden_scope
                - dte_type_not_allowed
                - ip_not_allowed
                - environment_not_allowed
                - sign_key_required
                - sign_key_invalid
                - sign_vault_locked
                - sign_vault_missing
                - invalid_request
                - validation_failed
                - not_found
                - method_not_allowed
                - idempotency_key_required
                - idempotency_key_reuse
                - idempotency_in_flight
                - prepare_token_invalid
                - rate_limited
                - amount_limit
                - mh_rejected
                - mh_unreachable
                - correlative_unavailable
                - no_storage_destination
                - service_unavailable
                - internal_error
            message:
              type: string
              description: Español, para el humano que lee el log. Puede cambiar.
            details:
              description: |
                Depende del código. `validation_failed` trae `issues`;
                `mh_rejected` trae `descripcionMsg`, `observaciones` y el
                correlativo gastado; `rate_limited` trae la ventana y
                `retryAfterSeconds`.

    Direccion:
      type: object
      description: Códigos del catálogo oficial del MH (CAT-012/013), no nombres.
      required: [departamento, municipio, complemento]
      properties:
        departamento: { type: string, example: "06" }
        municipio: { type: string, example: "20" }
        distrito: { type: string, example: "01" }
        complemento: { type: string, example: "CC Metrocentro San Miguel, Local 105" }

    Receptor:
      type: object
      description: |
        O se nombra un cliente ya guardado en Facta (`customerId`) o se escribe
        entero. Se pueden combinar: lo explícito gana sobre lo guardado, para
        que una venta con otro correo no obligue a editar la ficha del cliente.

        Un CCF (03) exige `numDocumento` (NIT), `nrc`, `nombre`, `codActividad`,
        `direccion` y `correo`. Una FE (01) admite el receptor entero, parcial o
        ninguno — sin receptor es el consumidor final anónimo, que es legal.
      properties:
        customerId:
          type: string
          format: uuid
          description: Un cliente de la empresa de la llave. Nunca de otra empresa.
        nombre: { type: string }
        tipoDocumento: { type: string, description: "CAT-022. 36 = NIT, 13 = DUI.", example: "36" }
        numDocumento: { type: string, example: "05110606161016" }
        nrc: { type: string, example: "2513113" }
        codActividad: { type: string, example: "46510" }
        descActividad: { type: string }
        direccion: { $ref: "#/components/schemas/Direccion" }
        telefono: { type: string, nullable: true }
        correo: { type: string, format: email }

    DocumentoRelacionado:
      type: object
      description: |
        El documento sellado que una nota ajusta. Dos formas:

        · **Por código** — `{ "codigoGeneracion": "…" }` y nada más. El servidor
          lee el tipo y la fecha del índice de su empresa. Es la forma que usa
          quien emitió el original por esta misma API.
        · **Entera** — `tipoDocumento`, `numeroDocumento` y `fechaEmision`, para
          ajustar algo que no está en nuestro índice.

        Un código que no está en su índice es `404`; uno que está pero sin sello
        es `400` — Hacienda no selló nada que ajustar.
      properties:
        codigoGeneracion: { type: string, format: uuid }
        tipoDocumento: { type: string, example: "03" }
        numeroDocumento: { type: string, maxLength: 36 }
        fechaEmision: { type: string, format: date }
        tipoGeneracion:
          type: integer
          enum: [1, 2]
          default: 2
          description: 1 en papel, 2 electrónico.

    ReceptorExportacion:
      type: object
      description: |
        Quien compra desde fuera del país. No tiene NRC ni dirección con códigos
        del catálogo: tiene país, una dirección en texto y si es persona o
        empresa. Su `descActividad` es texto libre — el registro del Ministerio
        solo cubre a contribuyentes salvadoreños.
      required: [nombre, numDocumento, codPais, nombrePais, complemento, tipoPersona, descActividad, correo]
      properties:
        nombre: { type: string }
        numDocumento: { type: string, description: Identificación fiscal extranjera o pasaporte. }
        tipoDocumento: { type: string, default: "37", description: "CAT-022; por defecto «otro»." }
        codPais: { type: string, minLength: 2, maxLength: 2, description: CAT-020., example: "US" }
        nombrePais: { type: string, minLength: 3, maxLength: 50 }
        complemento: { type: string, description: La dirección, en texto libre. }
        tipoPersona: { type: integer, enum: [1, 2], description: 1 natural, 2 jurídica. }
        descActividad: { type: string, minLength: 5, maxLength: 150 }
        correo: { type: string, format: email }
        telefono: { type: string, nullable: true }
        nombreComercial: { type: string, nullable: true }

    Exportacion:
      type: object
      required: [tipoItemExpor]
      description: |
        Lo que hace de una venta una exportación. La tasa es **cero**, y tanto la
        línea como el resumen llevan el tributo C3 con valor 0 — un `null` ahí lo
        rechaza Hacienda con «DEBE PROPORCIONAR UN VALOR».
      properties:
        tipoItemExpor: { type: integer, enum: [1, 2, 3], description: 1 bienes, 2 servicios, 3 ambos. }
        incoterms:
          type: string
          description: |
            **Solo el código** de CAT-031 (`01`..`11`). La descripción la pone el
            servidor desde su copia del catálogo: pedirle a un integrador que
            escriba la redacción del Ministerio es pedirle que la escriba mal.
            Sin incoterm, el código y su descripción salen `null` juntos.
          example: "09"
        recintoFiscal: { type: string, nullable: true, description: "CAT-027; null en exportación de servicios." }
        tipoRegimen: { type: string, nullable: true, description: CAT-033. }
        regimen: { type: string, nullable: true, description: CAT-028. }
        flete:
          type: number
          minimum: 0
          description: Monto pactado, no un cálculo. El servidor lo suma al total de la operación.
        seguro: { type: number, minimum: 0, description: Íd. }

    ReceptorSujetoExcluido:
      type: object
      description: |
        **El VENDEDOR**, no el comprador — el 14 es el único documento del
        catálogo que corre al revés: lo emite quien compra, para documentar una
        compra a alguien que no está en el registro de IVA. Por eso no tiene NRC
        (si lo manda, se ignora: el esquema no tiene dónde ponerlo) y por eso no
        hay IVA en ninguna parte del documento.
      required: [numDocumento, nombre, direccion]
      properties:
        numDocumento:
          type: string
          description: |
            Un DUI se normaliza a `########-#` y un NIT a solo dígitos. El
            Ministerio rechaza en vuelo el otro formato.
        tipoDocumento: { type: string, default: "13", description: "CAT-022; por defecto DUI." }
        nombre: { type: string }
        direccion:
          allOf: [{ $ref: "#/components/schemas/Direccion" }]
          description: 'No es opcional aquí: el esquema de la FSE la declara objeto llano.'
        codActividad: { type: string, nullable: true, description: "CAT-019. Con menos de 5 caracteres sale null, junto con su descripción — no se inventa un código." }
        telefono: { type: string, nullable: true }
        correo: { type: string, format: email, nullable: true }

    Item:
      type: object
      required: [descripcion, cantidad, precioUni]
      properties:
        descripcion: { type: string, minLength: 1 }
        cantidad:
          type: number
          exclusiveMinimum: 0
          description: |
            Hasta 8 decimales, los que la Normativa admite en el cuerpo. Se
            firma tal cual llega (nada se redondea a dos), y el importe de la
            línea y su IVA salen también con hasta 8 decimales; solo el
            resumen va a dos.
        precioUni:
          type: number
          minimum: 0
          description: |
            Hasta 8 decimales, como `cantidad`.

            **Depende del tipo, y el servidor no adivina.** En una FE (01)
            INCLUYE el IVA. En un CCF (03) y en las notas (05/06) lo EXCLUYE. En
            una exportación (11) no hay IVA que incluir. En una FSE (14) el
            precio es el precio: no se le suma ni se le quita nada. Así lo
            definen los esquemas oficiales.
        codigo: { type: string, nullable: true }
        tipoItem: { type: integer, description: "CAT-011. 1 bien, 2 servicio, 3 ambos, 4 otro.", default: 2 }
        uniMedida: { type: integer, description: "CAT-014. 59 = unidad.", default: 59 }
        numeroDocumento:
          type: string
          nullable: true
          description: |
            En una nota (05/06), a cuál de los `documentosRelacionados`
            pertenece esta línea. Opcional con uno solo; **obligatorio en cuanto
            haya más de uno**, porque si no todas las líneas caerían sobre el
            primero sin decirlo.

    SolicitudDte:
      type: object
      required: [tipoDte, items]
      description: |
        Un solo cuerpo para los seis tipos, y cada tipo exige lo suyo. La tabla
        de quién pide qué:

        | | 01 | 03 | 05 / 06 | 11 | 14 |
        |---|---|---|---|---|---|
        | **precio de la línea** | con IVA | sin IVA | sin IVA | sin IVA (tasa cero) | el precio, tal cual |
        | **receptor** | opcional | inscrito | inscrito | extranjero | el VENDEDOR |
        | **campos propios** | — | — | `documentosRelacionados`, y `numPagoElectronico` solo en 06 | `exportacion` | `aplicarReteRenta` |

        Mandar el campo propio de un tipo en otro es `400`, no un campo
        ignorado en silencio: si alguien manda `aplicarReteRenta` en una
        factura, cree que está reteniendo y no está reteniendo nada.
      properties:
        tipoDte:
          type: string
          enum: ["01", "03", "05", "06", "11", "14"]
          description: |
            `01` factura · `03` crédito fiscal · `05` nota de crédito · `06` nota
            de débito · `11` exportación · `14` sujeto excluido. Los demás del
            catálogo llegan con su tanda.
        receptor:
          description: |
            Su forma depende del tipo: `Receptor` para 01/03/05/06,
            `ReceptorExportacion` para el 11 y `ReceptorSujetoExcluido` para el
            14. Una FE (01) puede no llevarlo — es el consumidor final anónimo.
          oneOf:
            - $ref: "#/components/schemas/Receptor"
            - $ref: "#/components/schemas/ReceptorExportacion"
            - $ref: "#/components/schemas/ReceptorSujetoExcluido"
            - type: "null"
        items:
          type: array
          minItems: 1
          maxItems: 2000
          items: { $ref: "#/components/schemas/Item" }
        documentosRelacionados:
          type: array
          minItems: 1
          maxItems: 50
          description: |
            **Obligatorio en 05 y 06, y prohibido en los demás.** Qué documento
            sellado ajusta esta nota. Cada entrada se escribe entera o se nombra
            por su `codigoGeneracion`, que el servidor completa desde el índice
            de SU empresa.
          items: { $ref: "#/components/schemas/DocumentoRelacionado" }
        numPagoElectronico:
          type: string
          maxLength: 100
          nullable: true
          description: Solo en la nota de débito (06). En otro tipo es `400`.
        exportacion:
          allOf: [{ $ref: "#/components/schemas/Exportacion" }]
          description: Obligatorio en el 11, y prohibido en los demás.
        aplicarReteRenta:
          type: boolean
          default: false
          description: |
            Solo en el 14. Retención de renta del 10 % (art. 156 CT) sobre
            servicios de una persona natural. **Nunca automática**: se pide.
        condicionOperacion: { type: integer, enum: [1, 2, 3], default: 1, description: 1 contado, 2 crédito, 3 otro. }
        plazo:
          type: string
          enum: ["01", "02", "03"]
          description: CAT-018 — días, meses o años. Solo se escribe si la operación es a crédito.
        periodo:
          type: integer
          minimum: 1
          description: Cuántos de esos plazos. Solo con `condicionOperacion` 2.
        formaPago: { type: string, description: "CAT-017. 01 = efectivo.", example: "01" }
        observaciones: { type: string, nullable: true }

    SolicitudFirma:
      type: object
      required: [prepareToken, documento]
      properties:
        prepareToken: { type: string, description: Tal cual lo devolvió `prepare`. Vence a los 15 min. }
        documento: { type: object, description: El documento canónico de `prepare`, sin tocar un centavo. }

    PersonaDeLaAnulacion:
      type: object
      required: [nombre, numDocumento]
      properties:
        nombre: { type: string }
        numDocumento:
          type: string
          description: |
            Sin guiones. Un NIT son 14 dígitos (o 9, si está homologado con el
            DUI) y un DUI son 9 dígitos: la Normativa 2.0 lo valida así y el
            ambiente de pruebas rechaza `05308546-5` desde el 16-sep-2026. Si
            manda el guion se lo quitamos antes de firmar, según el tipo.
          example: "012345678"
        tipoDocumento:
          type: string
          description: |
            CAT-022 — 36 NIT, 13 DUI, 03 pasaporte, 02 carné de residente, 37
            otro. **Mándelo siempre.** El tipo se elige junto al número, donde
            alguien lo escribe, y viaja con él; no es algo que se pueda leer de
            la forma del número, porque nueve dígitos son a la vez un DUI y un
            NIT homologado con ese DUI, y la misma persona puede ser cualquiera
            de los dos.

            Si lo omite lo deducimos, y es solo una red: 14 dígitos es NIT (36),
            9 dígitos o `########-#` es DUI (13) —porque aquí se nombran
            PERSONAS— y cualquier otra cosa es «otro» (37). Una deducción
            equivocada no la atrapa el esquema; la atrapa el Ministerio, en
            vuelo, y entonces el evento ya gastó su intento.
          example: "13"

    SolicitudAnulacion:
      type: object
      required: [tipoAnulacion, responsable, solicita]
      properties:
        tipoAnulacion:
          type: integer
          enum: [1, 2, 3]
          description: 1 error en el documento, 2 rescisión, 3 otro.
        motivo:
          type: string
          nullable: true
          description: Obligatorio en los tipos 1 y 3; el campo 111 describe el error.
        codigoGeneracionReemplazo:
          type: string
          format: uuid
          nullable: true
          description: |
            El documento que reemplaza al anulado. Obligatorio en los tipos 1 y
            3, y prohibido en el 2.
        responsable:
          allOf: [{ $ref: "#/components/schemas/PersonaDeLaAnulacion" }]
          description: Quien responde por la anulación ante Hacienda.
        solicita:
          allOf: [{ $ref: "#/components/schemas/PersonaDeLaAnulacion" }]
          description: Quien la pidió. Puede ser la misma persona.

    DteAnulado:
      type: object
      properties:
        estado: { type: string, const: invalidado }
        codigoGeneracion: { type: string, format: uuid, description: El del documento anulado. }
        numeroControl: { type: string }
        tipoDte: { type: string }
        ambiente: { type: string }
        yaEstabaInvalidado:
          type: boolean
          description: Presente y `true` cuando el documento ya estaba anulado y no se mandó nada.
        evento:
          type: object
          description: El documento de anulación, que es un documento aparte.
          properties:
            codigoGeneracion: { type: string, format: uuid }
            selloRecibido: { type: string, description: La prueba de que quedó anulado. }
            fhProcesamiento: { type: string }
            observaciones: { type: array, items: { type: string } }
            tipoAnulacion: { type: integer, enum: [1, 2, 3] }
        documento: { type: object, description: El evento canónico, tal como se firmó. }
        jws: { type: string, description: El evento firmado. Archívelo como archiva el DTE. }
        anotadoEnElIndice:
          type: boolean
          description: |
            Si además pudimos anotarlo en nuestro libro. `false` no cambia que el
            documento está anulado —el sello de Hacienda es lo que manda— pero
            merece una mirada en la bitácora de peticiones.

    Totales:
      type: object
      description: |
        Los calcula `dte-core` en el servidor. **El cliente los transporta y no
        los recalcula nunca.**

        `totalIva` es el IVA de verdad, venga de donde venga: una FE lo lleva en
        `resumen.totalIva` y un CCF lo desglosa en `resumen.tributos` código 20
        dejando aquel campo vacío. Leer un solo sitio acierta la mitad de las
        veces, y la mitad en la que falla es cada crédito fiscal.
      properties:
        totalNoSuj: { type: number }
        totalExenta: { type: number }
        totalGravada: { type: number }
        totalDescu: { type: number }
        totalIva: { type: number }
        montoTotalOperacion: { type: number }
        totalPagar: { type: number }
        totalLetras: { type: string, example: VEINTIOCHO 25/100 DOLARES }

    DteSellado:
      type: object
      required: [estado, codigoGeneracion, numeroControl, selloRecibido, documento, jws]
      properties:
        estado: { type: string, const: sellado }
        codigoGeneracion: { type: string, format: uuid }
        numeroControl: { type: string, example: DTE-03-M001P001-000000000000175 }
        tipoDte: { type: string }
        ambiente: { type: string, enum: ["00", "01"], description: El de la llave y su empresa, que son el mismo. }
        fecEmi: { type: string, format: date }
        horEmi: { type: string, example: "15:59:27" }
        selloRecibido: { type: string, description: La prueba. Sin sello no hay documento, aunque el MH diga PROCESADO. }
        fhProcesamiento: { type: string }
        observaciones: { type: array, items: { type: string }, description: Un sello CON observaciones sigue siendo un sello, pero conviene leerlas. }
        totales: { $ref: "#/components/schemas/Totales" }
        documento: { type: object, description: El DTE canónico, tal como se firmó. }
        jws:
          type: string
          description: |
            El JWS compacto RS512. **Es esto lo que hay que archivar**: volver a
            serializar `documento` no reproduce los bytes cuya firma Hacienda
            validó.
        representacionGrafica:
          type: string
          format: byte
          nullable: true
          description: |
            El PDF del documento, en base64 — la misma hoja que imprime la app,
            del catálogo de 20 plantillas de `@facta/rg`. Lleva el sello y el
            QR, así que solo existe DESPUÉS de que el MH conteste.

            La plantilla, la franja del plan gratuito y el logo salen de la
            empresa EMISORA; no se piden en la petición.

            `null` no es un error: el artefacto fiscal es el `jws` y el sello ya
            está dado. Un PDF que no se pudo dibujar no invalida una emisión que
            sí ocurrió.
        almacenamiento:
          type: string
          enum: [retencion, ninguno]
          description: |
            Si además del documento que lleva en esta respuesta quedó una copia
            en el área de retención (`retencion`) o no (`ninguno`). El
            almacenamiento nunca bloquea el sello: un fallo aquí no cambia que
            el documento está sellado.

    DteEnContingencia:
      type: object
      description: |
        Firmado y sin respuesta del MH. Lleva los mismos campos que el sellado
        salvo los que solo existen con sello: no hay `selloRecibido`, no hay
        `fhProcesamiento` y no hay `representacionGrafica` — la hoja necesita el
        sello y el QR, así que todavía no se puede dibujar.
      properties:
        estado: { type: string, const: contingencia }
        codigoGeneracion: { type: string, format: uuid }
        numeroControl: { type: string }
        tipoDte: { type: string }
        ambiente: { type: string }
        fecEmi: { type: string, format: date }
        horEmi: { type: string }
        detalle: { type: string, description: Por qué no se alcanzó al MH. }
        documento: { type: object }
        jws: { type: string }
        almacenamiento:
          type: string
          enum: [retencion, ninguno]
          description: |
            Si quedó copia en el área de retención. La red es la misma que en el
            sellado, y aquí importa más: es el único sitio, aparte de esta
            respuesta, donde existe el documento firmado mientras Hacienda no
            conteste.

    DtePreparado:
      type: object
      properties:
        estado: { type: string, const: preparado }
        codigoGeneracion: { type: string, format: uuid }
        numeroControl: { type: string }
        tipoDte: { type: string }
        ambiente: { type: string }
        totales: { $ref: "#/components/schemas/Totales" }
        documento: { type: object }
        prepareToken: { type: string }

    DteConsultado:
      type: object
      properties:
        estado:
          type: string
          description: Un solo vocabulario, en español, responda el índice o la reserva.
          enum: [sellado, firmado, rechazado, contingencia, invalidado, reservado, liberado, descartado]
        codigoGeneracion: { type: string, format: uuid }
        numeroControl: { type: string }
        tipoDte: { type: string }
        ambiente: { type: string }
        fecEmi: { type: string, format: date }
        horEmi: { type: string, nullable: true }
        selloRecibido: { type: string, nullable: true }
        observaciones: { type: array, items: { type: string } }
        motivo: { description: Las palabras del MH cuando el documento fue rechazado., nullable: true }
        totales:
          type: object
          description: |
            Del documento sellado salen `totalGravada`, `totalIva` y
            `totalPagar`. De uno **rechazado** —que no tiene fila de índice, y
            se contesta desde su reserva— sale solo `totalPagar`, y tampoco hay
            `horEmi`, `observaciones` ni `receptor`.
        receptor: { type: object, nullable: true }

    PaginaDeDocumentos:
      type: object
      required: [documentos, siguiente]
      properties:
        documentos:
          type: array
          items: { $ref: "#/components/schemas/DteEnLista" }
        siguiente:
          type: string
          nullable: true
          description: |
            El cursor de la página siguiente, o `null` cuando no hay más. Opaco:
            páselo tal cual en `?cursor=`. Nunca una cadena vacía — un cliente
            la pediría igual.

    DteEnLista:
      type: object
      description: |
        Lo que hace falta para reconciliar: qué número, qué estado, cuánto y a
        quién. El documento entero se pide por su código con
        `GET /v1/dte/{codigoGeneracion}` o `…/file`.
      properties:
        estado:
          type: string
          enum: [sellado, firmado, contingencia, invalidado, rechazado]
        codigoGeneracion: { type: string, format: uuid }
        numeroControl: { type: string }
        tipoDte: { type: string }
        fecEmi: { type: string, format: date }
        horEmi: { type: string }
        selloRecibido: { type: string, nullable: true }
        totales:
          type: object
          properties:
            totalGravada: { type: number }
            totalIva: { type: number }
            totalPagar: { type: number }
        receptor:
          type: object
          properties:
            nombre: { type: string, nullable: true }
            numDocumento: { type: string, nullable: true }

    DocumentoRetenido:
      type: object
      description: |
        Una fila del área de retención — evidencia de estado, nunca una ruta
        de bucket ni contenido.
      properties:
        codigoGeneracion: { type: string, format: uuid }
        ambiente: { type: string, enum: ["00", "01"] }
        whereLanded:
          type: string
          enum: [holding, synced]
          description: '"holding": solo existe aquí. "synced": ya confirmaste que aterrizó en tu destino real.'
        gaveUp: { type: boolean, description: Se agotaron los tres intentos del barrido de cinco minutos. }
        attempts: { type: integer }
        expiresAt: { type: string, format: date-time, description: Una hora después de firmado. }
        downloadedAt: { type: string, format: date-time, nullable: true }
        downloadCount: { type: integer, description: Evidencia, no un cupo — descargar dos veces no se niega. }
        syncedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }

    Status:
      type: object
      properties:
        ok: { type: boolean }
        version: { type: string, const: v1 }
        ambiente:
          type: string
          enum: ["00", "01"]
          description: |
            El de la LLAVE. `/v1/status` es la única ruta que no exige ambiente
            de pruebas, así que una llave `facta_live_` puede contestar `01`
            aquí y ser rechazada en cuanto intente emitir.
        emisor:
          type: object
          nullable: true
          properties:
            nit: { type: string }
            nombre: { type: string }
            ambiente: { type: string }
        llave:
          type: object
          properties:
            keyId: { type: string }
            label: { type: string, nullable: true }
            modo: { type: string, enum: [custodian, byok] }
            alcances: { type: array, items: { type: string, enum: [issue, query, download] } }
            tiposDte: { type: array, items: { type: string } }
            venceEl: { type: string, format: date-time, nullable: true }
        firma:
          type: object
          description: |
            Si esta llave puede firmar, y con qué certificado. **Existencia
            nada más**: nada del contenido del vault de firma se puede leer por
            ninguna ruta, en ninguna forma — es la invariante de §6.1 del plan,
            y hay un test de arquitectura que falla si alguien la rompe.
          properties:
            vaultDeFirma:
              type: boolean
              description: La llave tiene su propio vault con el certificado del emisor.
            origenDeLaFirma:
              type: string
              enum: [vault, plataforma, sin-provisionar]
              description: |
                `vault`: el certificado del emisor, abierto con
                `X-Facta-Sign-Key`. `plataforma`: llave anterior al diseño de
                los dos vaults, que todavía firma con el certificado de Facta —
                camino con fecha de retiro. `sin-provisionar`: no puede firmar.
            cabecera:
              type: string
              const: x-facta-sign-key
        limites:
          type: object
          description: |
            Los techos se cuentan ANTES de trabajar, con el patrón de
            `email_relay_log`: una petición que muere a medio camino igual gastó
            su cupo. Es conservador a propósito — con trabajo irreversible al
            otro lado de la puerta, el que pierde un turno es el integrador
            honesto, no el ladrón.
          properties:
            hora: { $ref: "#/components/schemas/Ventana" }
            dia: { $ref: "#/components/schemas/Ventana" }
            estado:
              allOf: [{ $ref: "#/components/schemas/Ventana" }]
              description: |
                La ventana propia de `/v1/status`, contada aparte de las dos de
                arriba. Preguntar por el estado ya no gasta el techo de emitir
                — que era justo lo que hacía que un monitor cada minuto se
                comiera la hora entera.
            montoMaximoPorDocumentoCentavos: { type: integer }

    Ventana:
      type: object
      nullable: true
      properties:
        limit: { type: integer }
        used: { type: integer }
        remaining: { type: integer }

# ── La tabla de errores, código por código ─────────────────────────────────
#
# `components.schemas.Error.code` ya enumera los códigos; esto dice, de cada
# uno, con qué estado sale, cuándo pasa y qué hacer. Vive aquí y no en un
# documento aparte por la misma razón que la taxonomía vive en `errors.ts` y no
# en el plan: una tabla de errores en otro archivo es una tabla que se queda
# vieja. `apps/web/src/api-docs.test.ts` la compara contra `STATUS_FOR` y falla
# si alguien añade un código sin explicarlo.
x-facta-errores:
  unauthorized:
    status: 401
    cuando: No llegó la cabecera `X-Facta-Key`.
    hacer: Mande la llave. Y nunca en `Authorization` — esa la lee el gateway.
  invalid_api_key:
    status: 401
    cuando: La llave no existe, está mal escrita o el secreto no coincide.
    hacer: Los tres casos tardan lo mismo y dicen lo mismo, a propósito. Revise que copió la llave entera, con el punto.
  key_revoked:
    status: 403
    cuando: Alguien la revocó desde la app.
    hacer: Una llave revocada no vuelve. Acuñe otra.
  key_expired:
    status: 403
    cuando: Pasó la fecha de vencimiento que se le puso al acuñarla.
    hacer: Acuñe otra desde Configuración → Llaves de la API.
  key_inactive:
    status: 403
    cuando: La llave está desactivada.
    hacer: Vuelva a activarla desde la app.
  forbidden_scope:
    status: 403
    cuando: A la llave le falta el alcance que la ruta exige.
    hacer: '`details.required` dice cuál hacía falta y `details.granted` los que tiene. El alcance se elige al acuñar y no se edita después.'
  dte_type_not_allowed:
    status: 403
    cuando: La llave no tiene permitido ese `tipoDte`.
    hacer: '`details.allowed` trae la lista. Acuñe una llave que incluya el tipo.'
  ip_not_allowed:
    status: 403
    cuando: La llave tiene lista de direcciones y la suya no está en ninguna entrada.
    hacer: Añada su dirección o su rango CIDR al acuñar. La dirección que se compara es la del extremo que conecta; una cabecera puesta por el cliente no la cambia.
  environment_not_allowed:
    status: 403
    cuando: 'El prefijo de la llave y su fila no coinciden, o el ambiente de la llave no es el de su empresa.'
    hacer: 'Si su empresa ya pasó a producción, acuñe una llave de producción; una de pruebas no puede emitir documentos reales.'
  sign_key_required:
    status: 401
    cuando: Falta `X-Facta-Sign-Key` en una ruta que firma — o llegó la de apertura de almacenamiento (`factauk_`), que no es esta.
    hacer: Mande la contraseña del vault de firma, la que empieza con `factask_` y se mostró una vez al acuñar.
  sign_key_invalid:
    status: 401
    cuando: Esa contraseña no abre el vault de firma.
    hacer: '`details.intentosRestantes` dice cuántos quedan antes de que la llave se suspenda sola.'
  sign_vault_locked:
    status: 403
    cuando: Cinco intentos fallidos en una hora. La llave queda suspendida.
    hacer: Esperar no lo arregla. Hay que reactivarla desde la app.
  sign_vault_missing:
    status: 409
    cuando: La llave no está provisionada para firmar, o el certificado que guarda es de otro NIT que el de la empresa.
    hacer: Reintentar no la arregla. Acuñe la llave de nuevo, que es lo que provisiona su vault de firma.
  invalid_request:
    status: 400
    cuando: Cuerpo ausente, que no es JSON, de más de 1 MB, un campo con la forma equivocada, o un código de generación que no tiene forma de UUID.
    hacer: '`details.field` señala el campo cuando lo hay. No gasta correlativo.'
  validation_failed:
    status: 422
    cuando: El documento no cumple el esquema oficial del Ministerio de Hacienda.
    hacer: '`details.issues` lista cada campo. **No gasta correlativo**: la validación corre antes de reservar.'
  not_found:
    status: 404
    cuando: No existe el documento, o el `customerId`, o la ruta.
    hacer: Un documento de otra empresa contesta exactamente lo mismo que uno que no existe. Es a propósito.
  method_not_allowed:
    status: 405
    cuando: La ruta existe, pero no con ese método.
    hacer: El mensaje dice cuál es el bueno.
  idempotency_key_required:
    status: 400
    cuando: Falta `Idempotency-Key` en una de las tres rutas que gastan correlativo.
    hacer: Elija una cadena por operación. Sin ella, un reintento por timeout quemaría un segundo número.
  idempotency_key_reuse:
    status: 422
    cuando: Ya usó esa `Idempotency-Key` con un cuerpo distinto.
    hacer: Use una nueva. Devolver la factura de ayer para la venta de hoy sería peor que fallar.
  idempotency_in_flight:
    status: 409
    cuando: La primera petición con esa llave todavía se está procesando.
    hacer: 'Reintente en unos segundos. No se encola a propósito: la primera puede estar gastando un correlativo ahora mismo.'
  prepare_token_invalid:
    status: 422
    cuando: El `prepareToken` venció, es de otra llave, o el documento cambió aunque sea un centavo.
    hacer: Vuelva a llamar a `/v1/dte/prepare`. Lo que se firma tiene que ser lo que obtuvo el número.
  rate_limited:
    status: 429
    cuando: La llave alcanzó su techo por hora o por día.
    hacer: '`details.window`, `details.remaining` y `details.retryAfterSeconds` dicen cuál y cuánto esperar.'
  amount_limit:
    status: 429
    cuando: El documento pasa del monto máximo por documento de la llave.
    hacer: Se comprueba antes de reservar, así que **no gasta correlativo**. Suba el techo de la llave o divida la operación.
  mh_rejected:
    status: 422
    cuando: Hacienda leyó el documento y lo negó.
    hacer: '**Sí gastó correlativo**, y `details` nombra `codigoGeneracion` y `numeroControl` para que la corrección reuse ESE número (§167). No es 502 porque es un veredicto sobre los datos, no una caída.'
  mh_unreachable:
    status: 502
    cuando: 'Reservado: la versión actual nunca lo devuelve.'
    hacer: Todo fallo de transporte sale como **202 contingencia**, porque para entonces el documento ya está firmado y se le debe a Hacienda.
  correlative_unavailable:
    status: 503
    cuando: No se pudo reservar el correlativo.
    hacer: Reintente con la MISMA `Idempotency-Key`. No se gastó número.
  no_storage_destination:
    status: 422
    cuando: La empresa no tiene ningún destino de almacenamiento conectado y verificado en los últimos 30 días.
    hacer: 'Conecte un destino desde la app. Corre antes que nada más, así que **no gasta correlativo**: firmar un documento que no tiene dónde aterrizar sería dejarlo sin ninguna copia.'
  service_unavailable:
    status: 503
    cuando: Algo nuestro no contestó — la base, el registro de peticiones, el de idempotencia.
    hacer: Reintente con la misma `Idempotency-Key`.
  internal_error:
    status: 500
    cuando: Un fallo que no estaba previsto.
    hacer: El mensaje nunca describe nuestras entrañas. Si se repite, escriba a soporte con la hora y el `keyId`.
