# RuralDTE — API v1 (OpenAPI 3.1)
#
# Especificación FIEL al código de las edge functions en apps/api/supabase/functions/.
# La API real son edge functions Supabase; el contrato es el mismo que consume
# @ruraldte/sdk. Montos = pesos chilenos ENTEROS (sin centavos).
#
# Emitir = encolar: POST /documents responde 202 { status: "queued" }. El SII
# confirma de forma ASÍNCRONA → pollea GET /documents/{id}/states o suscribe
# webhooks (dte.aceptado / dte.rechazado / …). trackId ≠ aceptación.
openapi: 3.1.0
info:
  title: RuralDTE API
  version: "1.0.0"
  description: |
    Motor de facturación electrónica chilena (DTE) certificado por el SII.
    Emite boletas y facturas desde tu software, tu WhatsApp o tu agente de IA,
    sin pagar por documento.

    ## Autenticación
    Todas las llamadas usan una **API key Bearer**:
    `Authorization: Bearer rdte_live_…` (producción) o `rdte_test_…` (sandbox/cert).
    La key fija el ambiente: una key `live` solo emite en `prod`; una `test` solo
    en `test`/`cert`. Se guarda solo `sha256(key)` + prefijo; la key cruda se ve
    **una sola vez** al crearla.

    ## Scopes
    Una key puede acotarse a un subconjunto de scopes (key sin scopes = full):
    `dte:create`, `dte:read`, `caf:read`, `caf:write`, `emisores:read`,
    `webhooks:write`. Una key también puede restringirse a un emisor
    (`scopeEmisorId`).

    ## Idempotencia
    Manda el header `Idempotency-Key` en `POST /documents` para que un reintento
    no encole dos veces el mismo documento (el folio es irreversible).

    ## Emitir = encolar (asíncrono)
    `POST /documents` responde **202** con `status: "queued"`. El worker drena la
    cola, firma con el motor y pollea el SII. Sigue el desenlace por
    `GET /documents/{id}/states` o por **webhooks** (ver el esquema
    `WebhookEvent`).

    ## Errores del SII explicados
    Cuando el SII devuelve un código (`sii_code`), la API lo traduce a
    `detalle_sii` = `{ code, glosa, causa, solucion }` (en tuteo chileno) para que
    sepas qué pasó y qué hacer. Ver el esquema `SiiExplanation`.
  contact:
    name: RuralDTE
    url: https://ruraldte.cl
servers:
  - url: https://api.ruraldte.cl
    description: Dominio propio (default del SDK) — proxea a las edge functions
  - url: https://wfaijmddlkrwkqhlmcwm.supabase.co/functions/v1
    description: Host directo del backend — también funciona

security:
  - ApiKey: []

tags:
  - name: Documentos
    description: Emitir (= encolar), listar, consultar estados y artefactos (XML/PDF) de DTE.
  - name: Folios
    description: Solicitar CAF al SII, ver stock y huecos a declarar (Declaración de Avance).
  - name: Emisores
    description: Alta de emisor + carga/revocación del certificado (.pfx) en custodia.
  - name: API keys
    description: Crear, listar y revocar API keys con scopes finos.
  - name: Webhooks
    description: Registrar endpoints firmados (HMAC) para eventos dte.* del ciclo de vida.
  - name: Reportes
    description: Ventas diarias, top receptores y alerta de folios bajos.
  - name: Programados
    description: Emisión recurrente (semanal/quincenal/mensual) — el cron la encola.
  - name: Intercambio
    description: Recibir un EnvioDTE entrante y devolver los 3 acuses firmados (Ley 19.983).
  - name: Portal del receptor
    description: Descarga pública de XML/PDF por token opaco (sin login).

paths:
  /v1-documents:
    post:
      tags: [Documentos]
      operationId: emitDocument
      summary: Emitir (= encolar) un DTE
      description: |
        Encola un documento. Responde **202** `{ status: "queued" }` — NO es
        aceptación del SII. Pollea `/{id}/states` o usa webhooks. Requiere el
        scope `dte:create`. Manda `Idempotency-Key` para reintentos seguros.
        Límite: 120 req/min por cuenta.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/UseDefaults'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmitInput' }
      responses:
        '202':
          description: Documento encolado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmitResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Scope insuficiente, ambiente no permitido por la key, o emisor fuera de scope.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: Emisor no existe o no pertenece a tu cuenta.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '409':
          description: Falta certificado / CAF / folios para ese tipo y ambiente.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422':
          description: Cuerpo inválido (montos descuadrados, RUT inválido, falta referencia en NC/ND, etc.).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Documentos]
      operationId: listDocuments
      summary: Listar documentos de la cuenta
      description: Más recientes primero. Requiere el scope `dte:read`.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Lista de documentos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documents:
                    type: array
                    items: { $ref: '#/components/schemas/DocumentSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-documents/batch:
    post:
      tags: [Documentos]
      operationId: emitBatch
      summary: Emitir un lote (≤ 100 documentos)
      description: |
        Emite un lote (boleta de alto volumen). No aborta si uno falla: devuelve
        resultado por documento. Idempotencia opcional por item
        (`idempotencyKey`). Responde 202 si al menos uno se encoló, 422 si todos
        fallaron. Límite: 120 req/min por cuenta.
      parameters:
        - $ref: '#/components/parameters/UseDefaults'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [documents]
              properties:
                documents:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: '#/components/schemas/BatchInput' }
      responses:
        '202':
          description: Lote procesado (al menos uno encolado).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchResult' }
        '422':
          description: Lote vacío, demasiado grande, o todos los items fallaron.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1-documents/{id}:
    get:
      tags: [Documentos]
      operationId: getDocument
      summary: Un documento (metadatos, opcionalmente con artefactos)
      description: |
        Metadatos del documento. Con `?incluir=pdf,xml,states` embebe los
        artefactos en una sola respuesta (`pdfBase64`, `xml`, `states`). Si un
        artefacto no está listo aún, su campo viene `null`.
      parameters:
        - $ref: '#/components/parameters/DocumentId'
        - name: incluir
          in: query
          description: Lista separada por comas. Cualquier combinación de `pdf`, `xml`, `states`.
          schema: { type: string, example: "pdf,xml,states" }
      responses:
        '200':
          description: Documento.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DocumentDetail' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-documents/{id}/states:
    get:
      tags: [Documentos]
      operationId: getDocumentStates
      summary: Línea de tiempo de estados
      description: |
        Historial de transiciones (queued → sent → accepted/…). Cada estado con
        `sii_code` trae `detalle_sii` traducido (glosa/causa/solución).
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Estados del documento.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  states:
                    type: array
                    items: { $ref: '#/components/schemas/DocumentState' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-documents/{id}/xml:
    get:
      tags: [Documentos]
      operationId: getDocumentXml
      summary: EnvioDTE firmado (XML)
      description: XML crudo del EnvioDTE firmado, codificado ISO-8859-1 (no JSON).
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: EnvioDTE firmado.
          content:
            application/xml:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-documents/{id}/pdf:
    get:
      tags: [Documentos]
      operationId: getDocumentPdf
      summary: Muestra impresa (PDF con timbre PDF417)
      description: PDF de la representación impresa. Render lazy + cacheado.
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Aún no hay XML firmado para renderizar.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-documents/{id}/share:
    post:
      tags: [Documentos]
      operationId: shareDocument
      summary: Crear (o reusar) el enlace del portal del receptor
      description: |
        Devuelve un token opaco (256 bits) y las URLs del portal público. El
        receptor descarga XML/PDF **sin login**. Idempotente: reusa un token
        activo si existe. El token expira (6 años) y es revocable.
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '201':
          description: Enlace de descarga.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ShareResult' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-folios:
    get:
      tags: [Folios]
      operationId: listFolioStock
      summary: Stock de folios por tipo
      description: 'Disponibles = range_hasta − next_folio + 1. Requiere scope `caf:read` (vía key con scopes).'
      parameters:
        - name: emisorId
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Stock.
          content:
            application/json:
              schema:
                type: object
                properties:
                  stock:
                    type: array
                    items: { $ref: '#/components/schemas/FolioStock' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-folios/request:
    post:
      tags: [Folios]
      operationId: requestFolios
      summary: Solicitar + timbrar un CAF al SII
      description: |
        Síncrono: descifra el .pfx en el borde de firma, solicita el CAF al SII
        del ambiente del emisor y persiste el rango. Requiere el scope
        `caf:write`. Límite: 10 req/min por cuenta (acción real al SII).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emisorId, tipo, cantidad]
              properties:
                emisorId: { type: string, format: uuid }
                tipo: { $ref: '#/components/schemas/DteTipo' }
                cantidad: { type: integer, minimum: 1 }
      responses:
        '200':
          description: CAF otorgado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FolioGrant' }
        '403':
          description: Falta el scope caf:write.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-folios/gaps:
    get:
      tags: [Folios]
      operationId: listFolioGaps
      summary: Folios quemados a declarar (Declaración de Avance)
      description: Folios que murieron sin que el SII los viera (huecos en la secuencia).
      parameters:
        - name: emisorId
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Huecos de folios.
          content:
            application/json:
              schema:
                type: object
                properties:
                  gaps:
                    type: array
                    items: { $ref: '#/components/schemas/FolioGap' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-emisores:
    get:
      tags: [Emisores]
      operationId: listEmisores
      summary: Listar emisores de la cuenta
      responses:
        '200':
          description: Emisores.
          content:
            application/json:
              schema:
                type: object
                properties:
                  emisores:
                    type: array
                    items: { $ref: '#/components/schemas/Emisor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Emisores]
      operationId: createEmisor
      summary: Alta de un emisor
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateEmisorInput' }
      responses:
        '201':
          description: Emisor creado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  emisor: { $ref: '#/components/schemas/Emisor' }
        '409':
          description: No se pudo crear (¿RUT+ambiente ya existe?).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422':
          description: RUT o razón social inválidos.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-emisores/{emisorId}/credentials:
    post:
      tags: [Emisores]
      operationId: uploadCredential
      summary: Cargar el certificado (.pfx) en custodia
      description: |
        El .pfx se valida con su password, se cifra de inmediato (envelope
        encryption) y se guarda en custodia. **El material nunca se retorna ni se
        loguea.** Solo se descifra en memoria, en el borde de firma.
      parameters:
        - name: emisorId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadCredentialInput' }
      responses:
        '201':
          description: Certificado en custodia.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  credentialId: { type: string, format: uuid }
                  message: { type: string }
        '422':
          description: .pfx inválido o contraseña incorrecta.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-emisores/{emisorId}/credentials/revoke:
    post:
      tags: [Emisores]
      operationId: revokeCredential
      summary: Revocar el certificado activo del emisor
      description: Deja de descifrarse para firmar (el material cifrado queda). Útil ante .pfx comprometido o rotación.
      parameters:
        - name: emisorId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Certificado revocado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  revoked: { type: integer }
                  message: { type: string }
        '404':
          description: Emisor inexistente o sin certificado activo.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-keys:
    get:
      tags: [API keys]
      operationId: listKeys
      summary: Listar API keys (sin la cruda ni el hash)
      responses:
        '200':
          description: Keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKeySummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [API keys]
      operationId: createKey
      summary: Crear una API key
      description: Devuelve la key cruda **una sola vez**. Guárdala.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                environment: { type: string, enum: [test, live], default: test }
                name: { type: string }
                scopes:
                  type: array
                  items: { $ref: '#/components/schemas/Scope' }
                scopeEmisorId: { type: string, format: uuid, description: Restringe la key a un emisor. }
      responses:
        '201':
          description: Key creada (cruda visible una vez).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
        '422':
          description: Scope inválido o cuerpo inválido.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-keys/{id}/revoke:
    post:
      tags: [API keys]
      operationId: revokeKey
      summary: Revocar una API key
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Key revocada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  revoked: { type: boolean }
        '404':
          description: Key no encontrada o ya revocada.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: Listar webhooks (sin el secret)
      responses:
        '200':
          description: Webhooks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Webhooks]
      operationId: registerWebhook
      summary: Registrar un endpoint de webhook
      description: |
        Requiere el scope `webhooks:write`. La `url` debe ser https y apuntar a un
        host público. Devuelve el `secret` **una sola vez**: cada entrega se firma
        HMAC-SHA256 y viaja en el header `X-RuralDTE-Signature` (+ timestamp).
        `events` es un subconjunto del catálogo (ver `WebhookEvent`); `[]` = todos.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, example: "https://tu-app.cl/webhooks/ruraldte" }
                events:
                  type: array
                  items: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '201':
          description: Webhook registrado (secret visible una vez).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookCreated' }
        '403':
          description: Falta el scope webhooks:write.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422':
          description: URL inválida o evento desconocido.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-webhooks/{id}/disable:
    post:
      tags: [Webhooks]
      operationId: disableWebhook
      summary: Deshabilitar un webhook
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Webhook deshabilitado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  active: { type: boolean }
        '404':
          description: Webhook no encontrado o ya deshabilitado.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-reportes/ventas:
    get:
      tags: [Reportes]
      operationId: reporteVentas
      summary: Serie diaria de ventas
      description: Conteo + montos CLP por día (zona horaria de Chile). Requiere scope `dte:read`.
      parameters:
        - { name: desde, in: query, schema: { type: string, format: date } }
        - { name: hasta, in: query, schema: { type: string, format: date } }
        - { name: emisorId, in: query, schema: { type: string, format: uuid } }
        - { name: ambiente, in: query, schema: { $ref: '#/components/schemas/Ambiente' } }
      responses:
        '200':
          description: Ventas diarias.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ventas:
                    type: array
                    items: { $ref: '#/components/schemas/VentaPeriodo' }
        '403':
          description: Falta el scope dte:read.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-reportes/top-receptores:
    get:
      tags: [Reportes]
      operationId: reporteTopReceptores
      summary: Top receptores por monto
      parameters:
        - { name: desde, in: query, schema: { type: string, format: date } }
        - { name: hasta, in: query, schema: { type: string, format: date } }
        - { name: limit, in: query, schema: { type: integer, default: 10, maximum: 100 } }
      responses:
        '200':
          description: Top receptores.
          content:
            application/json:
              schema:
                type: object
                properties:
                  receptores:
                    type: array
                    items: { $ref: '#/components/schemas/TopReceptor' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-reportes/folios-bajos:
    get:
      tags: [Reportes]
      operationId: reporteFoliosBajos
      summary: Cursores con stock bajo
      description: Folios con disponibles ≤ umbral. Requiere scope `caf:read`.
      parameters:
        - { name: umbral, in: query, schema: { type: integer, default: 100 } }
      responses:
        '200':
          description: Folios bajos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  foliosBajos:
                    type: array
                    items: { $ref: '#/components/schemas/FolioBajo' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-scheduled:
    get:
      tags: [Programados]
      operationId: listSchedules
      summary: Listar programaciones
      description: Resumen + diagnóstico de la última corrida del cron. Requiere scope `dte:read`.
      responses:
        '200':
          description: Programaciones.
          content:
            application/json:
              schema:
                type: object
                properties:
                  schedules:
                    type: array
                    items: { $ref: '#/components/schemas/ScheduledDocument' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Programados]
      operationId: createSchedule
      summary: Programar un DTE recurrente
      description: |
        Guarda la plantilla del DTE (misma forma que emit) + la recurrencia. Un
        cron diario lo encola en su fecha. Requiere scope `dte:create`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateScheduleInput' }
      responses:
        '201':
          description: Programación creada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleCreated' }
        '400':
          description: Validación de la plantilla o de la recurrencia.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '403':
          description: Scope o ambiente no permitidos por la key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-scheduled/{id}/disable:
    post:
      tags: [Programados]
      operationId: disableSchedule
      summary: Pausar una programación
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Programación pausada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleToggle' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-scheduled/{id}/enable:
    post:
      tags: [Programados]
      operationId: enableSchedule
      summary: Reactivar una programación
      parameters:
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Programación reactivada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleToggle' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-intercambio:
    post:
      tags: [Intercambio]
      operationId: receiveIntercambio
      summary: Recibir un EnvioDTE entrante y devolver los acuses firmados
      description: |
        Un tercero te envió un DTE. Devuelve los 3 acuses **firmados**:
        RecepcionEnvio (acuse de recibo) + ResultadoDTE (resultado comercial) +
        EnvioRecibos (recibo de mercaderías, Ley 19.983). En cert los acusa a la
        casilla del SII; en prod, a `replyTo` si se provee.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IntercambioInput' }
      responses:
        '201':
          description: EnvioDTE acusado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IntercambioResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1-portal/{token}:
    get:
      tags: [Portal del receptor]
      operationId: portalMetadata
      summary: Metadatos del documento (público, por token)
      description: |
        PÚBLICO — sin API key. El token (256 bits) es la credencial. Toda
        respuesta lleva `X-Robots-Tag: noindex`. El token mapea a UN documento.
      security: []
      parameters:
        - $ref: '#/components/parameters/PortalToken'
      responses:
        '200':
          description: Metadatos + links de descarga.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PortalMetadata' }
        '404':
          description: Enlace inválido o expirado.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1-portal/{token}/xml:
    get:
      tags: [Portal del receptor]
      operationId: portalXml
      summary: Descargar el XML (público, por token)
      security: []
      parameters:
        - $ref: '#/components/parameters/PortalToken'
      responses:
        '200':
          description: EnvioDTE firmado.
          content:
            application/xml:
              schema: { type: string }
        '404':
          description: Enlace inválido o expirado.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1-portal/{token}/pdf:
    get:
      tags: [Portal del receptor]
      operationId: portalPdf
      summary: Descargar el PDF (público, por token)
      security: []
      parameters:
        - $ref: '#/components/parameters/PortalToken'
      responses:
        '200':
          description: Muestra impresa.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Enlace inválido o expirado.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      bearerFormat: rdte_(test|live)_…
      description: |
        API key Bearer. `rdte_live_…` para producción (ambiente `prod`),
        `rdte_test_…` para sandbox/cert (ambientes `test`/`cert`).

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Clave de idempotencia para reintentos seguros de emisión (el folio es irreversible).
      schema: { type: string, example: "orden-123" }
    UseDefaults:
      name: X-Use-Defaults
      in: header
      required: false
      description: >
        Si está presente (y distinto de "false"/"0"), el servidor autocompleta campos
        opcionales para que mandes lo mínimo: `ambiente` se deriva del ambiente de la API
        key (live→prod, test→cert) si lo omites, y en un documento AFECTO donde solo envías
        `montos.total` se calcula el split de IVA 19% (`neto` = redondeo(total/1.19),
        `iva` = total − neto). No aplica a exentos/exportación/43/46.
      schema: { type: string, example: "true" }
    DocumentId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    PortalToken:
      name: token
      in: path
      required: true
      description: Token opaco de 64 chars hex (256 bits).
      schema: { type: string, pattern: '^[0-9a-f]{64}$' }

  responses:
    Unauthorized:
      description: API key o sesión inválida.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Recurso no encontrado (o fuera de tu cuenta).
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: Demasiadas solicitudes. Reintenta tras el `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Segundos a esperar.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  schemas:
    Error:
      type: object
      description: Forma estándar de error. `error` es un código estable; `detail` es legible.
      properties:
        error: { type: string, example: "insufficient_scope" }
        detail: { type: string, example: "Falta el scope dte:create" }

    Ambiente:
      type: string
      enum: [test, cert, prod]
      description: La key fija el ambiente permitido (live→prod, test→test/cert).

    Scope:
      type: string
      enum: [dte:create, dte:read, caf:read, caf:write, emisores:read, webhooks:write]

    WebhookEvent:
      type: string
      description: |
        Catálogo de eventos de webhook (SSOT del backend):
          - `dte.encolado` — documento aceptado en la cola (202); trackId ≠ aceptación.
          - `dte.aceptado` — SII aceptó (DOK).
          - `dte.con_reparos` — SII aceptó con reparos/discrepancias (DNK).
          - `dte.rechazado` — SII rechazó (RCH).
          - `dte.manual_pending` — el worker no pudo emitir automáticamente → acción manual.
          - `folios.bajos` — el stock de folios (CAF) de un tipo está por agotarse.
          - `sii.incidente` — el health-gate detectó al SII caído/degradado.
          - `sii.recuperado` — el SII volvió a estar operativo.
      enum:
        - dte.encolado
        - dte.aceptado
        - dte.con_reparos
        - dte.rechazado
        - dte.manual_pending
        - folios.bajos
        - sii.incidente
        - sii.recuperado

    DteTipo:
      type: integer
      description: |
        Tipo de DTE certificado. Uno de: 39 (boleta), 41 (boleta exenta),
        33 (factura), 34 (factura exenta), 43 (liquidación-factura),
        46 (factura de compra), 52 (guía de despacho), 56 (nota de débito),
        61 (nota de crédito), 110 (factura de exportación),
        111 (nota de débito exportación), 112 (nota de crédito exportación).
      enum: [39, 41, 33, 34, 43, 46, 52, 56, 61, 110, 111, 112]

    EmitReceptor:
      type: object
      required: [rut, razonSocial]
      properties:
        rut: { type: string, description: "RUT chileno con DV (se valida módulo 11).", example: "11111111-1" }
        razonSocial: { type: string, maxLength: 100, example: "ACME SpA" }
        giro: { type: string, maxLength: 40, description: "GiroRecep. Obligatorio en liquidación-factura (43)." }
        direccion: { type: string, maxLength: 70, description: "DirRecep. OBLIGATORIO en facturas/notas/guía (33/34/43/46/52/56/61) — el SII rechaza sin él (HED-3-845).", example: "Av. Providencia 1234" }
        comuna: { type: string, maxLength: 20, description: "CmnaRecep. OBLIGATORIA en los mismos tipos que direccion (HED-3-846).", example: "Providencia" }
        ciudad: { type: string, maxLength: 20, description: "CiudadRecep (opcional).", example: "Santiago" }
        email: { type: string, format: email }
        phone: { type: string, description: "Metadato (NO es campo del DTE): contacto WhatsApp del receptor." }
        extranjero:
          type: object
          properties:
            numId: { type: string }
            nacionalidad: { type: integer }

    EmitMontos:
      type: object
      required: [total]
      description: Pesos chilenos ENTEROS. En documentos afectos, neto+iva+exento debe igualar total.
      properties:
        total: { type: integer, minimum: 1, example: 1190 }
        neto: { type: integer, description: "Requerido en documentos afectos.", example: 1000 }
        iva: { type: integer, description: "Requerido en documentos afectos.", example: 190 }
        exento: { type: integer }

    EmitReference:
      type: object
      required: [tipo, folio, fecha]
      properties:
        tipo: { type: integer }
        folio: { type: integer }
        fecha: { type: string, format: date }
        razon: { type: string }
        codigo: { type: integer }

    EmitInput:
      type: object
      required: [emisorId, tipo, ambiente, receptor, montos]
      properties:
        emisorId: { type: string, format: uuid }
        tipo: { $ref: '#/components/schemas/DteTipo' }
        ambiente: { $ref: '#/components/schemas/Ambiente' }
        receptor: { $ref: '#/components/schemas/EmitReceptor' }
        montos: { $ref: '#/components/schemas/EmitMontos' }
        exenta: { type: boolean, description: "Normalmente lo deriva el server (34/41/export/exento)." }
        glosa: { type: string }
        paymentForm: { type: integer, description: "FmaPago (obligatorio 33/34/43 por Anexo v2.5)." }
        serviceIndicator: { type: integer }
        exemptIndicator: { type: integer }
        references:
          type: array
          description: "Obligatorio en NC/ND (56/61/111/112)."
          items: { $ref: '#/components/schemas/EmitReference' }
        despacho:
          type: object
          description: "Guía de despacho (52)."
          properties:
            tipoDespacho: { type: integer }
            indTraslado: { type: integer }
          required: [indTraslado]
        transporte:
          type: object
          additionalProperties: true
        exportacion:
          type: object
          description: "Exportación (110/111/112)."
          required: [tpoMoneda]
          properties:
            tpoMoneda: { type: string }
            otraMoneda: { type: object, additionalProperties: true }
            fmaPagExp: { type: integer }
            fchCancel: { type: string, format: date }
            indServicio: { type: integer }
        liquidacion:
          type: object
          description: "Liquidación-factura (43): líneas (cada una con tpoDocLiq) + comisiones + split IVA."
          properties:
            items: { type: array, items: { type: object, additionalProperties: true } }
            comisiones: { type: array, items: { type: object, additionalProperties: true } }
            exento: { type: integer }
            ivaProp: { type: integer }
            ivaTerc: { type: integer }
            valComNeto: { type: integer }
            valComExe: { type: integer }
            valComIVA: { type: integer }
        compra:
          type: object
          description: "Factura de compra (46): cambio de sujeto / retención (opcional)."
          additionalProperties: true

    BatchInput:
      allOf:
        - $ref: '#/components/schemas/EmitInput'
        - type: object
          properties:
            idempotencyKey: { type: string }

    EmitResult:
      type: object
      description: '202: el documento entró a la cola.'
      properties:
        id: { type: string, format: uuid }
        folio: { type: integer }
        status: { type: string, example: "queued" }
        tipo: { $ref: '#/components/schemas/DteTipo' }
        ambiente: { $ref: '#/components/schemas/Ambiente' }

    BatchItemResult:
      type: object
      properties:
        index: { type: integer }
        ok: { type: boolean }
        id: { type: string, format: uuid }
        folio: { type: integer }
        status: { type: string }
        tipo: { type: integer }
        ambiente: { type: string }
        error: { type: string }
        detail: { type: string }

    BatchResult:
      type: object
      properties:
        count: { type: integer }
        results:
          type: array
          items: { $ref: '#/components/schemas/BatchItemResult' }

    ShareResult:
      type: object
      properties:
        token: { type: string }
        url: { type: string, format: uri }
        xmlUrl: { type: string, format: uri }
        pdfUrl: { type: string, format: uri }
        expiresAt: { type: string, format: date-time }

    DocumentSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        tipo: { type: integer }
        folio: { type: integer }
        ambiente: { type: string }
        receptor_rut: { type: [string, "null"] }
        receptor_razon_social: { type: [string, "null"] }
        monto: { type: integer }
        status: { type: string }
        created_at: { type: string, format: date-time }

    SiiExplanation:
      type: object
      description: Código del SII traducido a algo accionable (tuteo chileno).
      properties:
        code: { type: string, example: "RCH" }
        glosa: { type: string, example: "DTE rechazado por el SII" }
        causa: { type: string }
        solucion: { type: string }

    DocumentState:
      type: object
      properties:
        from_status: { type: [string, "null"] }
        to_status: { type: string }
        sii_code: { type: [string, "null"] }
        detail: { type: [string, "null"] }
        ts: { type: string, format: date-time }
        detalle_sii: { $ref: '#/components/schemas/SiiExplanation' }

    DocumentDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        tipo: { type: integer }
        folio: { type: integer }
        ambiente: { type: string }
        status: { type: string }
        monto: { type: integer }
        monto_neto: { type: [integer, "null"] }
        monto_iva: { type: [integer, "null"] }
        monto_exento: { type: [integer, "null"] }
        receptor_rut: { type: [string, "null"] }
        receptor_razon_social: { type: [string, "null"] }
        track_id: { type: [string, "null"] }
        sii_code: { type: [string, "null"] }
        detalle_sii: { $ref: '#/components/schemas/SiiExplanation' }
        created_at: { type: string, format: date-time }
        states:
          type: array
          items: { $ref: '#/components/schemas/DocumentState' }
        xml: { type: [string, "null"], description: "Solo con ?incluir=xml." }
        pdfBase64: { type: [string, "null"], description: "Solo con ?incluir=pdf." }

    FolioStock:
      type: object
      properties:
        emisor_id: { type: string, format: uuid }
        tipo: { type: integer }
        ambiente: { type: string }
        next_folio: { type: integer }
        range_hasta: { type: integer }
        disponibles: { type: integer }

    FolioGrant:
      type: object
      properties:
        tipo: { type: integer }
        rango_desde: { type: integer }
        rango_hasta: { type: integer }
        cantidad: { type: integer }

    FolioGap:
      type: object
      properties:
        emisor_id: { type: string, format: uuid }
        tipo: { type: integer }
        ambiente: { type: string }
        folio: { type: integer }
        motivo: { type: [string, "null"] }
        status: { type: string }
        document_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        declared_at: { type: [string, "null"] }

    VentaPeriodo:
      type: object
      properties:
        dia: { type: string, format: date }
        documentos: { type: integer }
        monto_total: { type: integer }
        monto_neto: { type: integer }
        monto_iva: { type: integer }

    TopReceptor:
      type: object
      properties:
        receptor_rut: { type: string }
        receptor_razon_social: { type: [string, "null"] }
        documentos: { type: integer }
        monto_total: { type: integer }

    FolioBajo:
      type: object
      properties:
        emisor_id: { type: string, format: uuid }
        tipo: { type: integer }
        ambiente: { type: string }
        disponibles: { type: integer }

    Emisor:
      type: object
      properties:
        id: { type: string, format: uuid }
        rut: { type: string }
        razon_social: { type: string }
        ambiente: { type: string }
        onboarding_state: { type: string }
        mandatario_status: { type: [string, "null"] }
        status: { type: string }
        created_at: { type: string, format: date-time }

    CreateEmisorInput:
      type: object
      required: [rut, razonSocial]
      properties:
        rut: { type: string, example: "78416626-0" }
        razonSocial: { type: string }
        giro: { type: string }
        acteco: { oneOf: [{ type: string }, { type: integer }] }
        direccion: { type: string }
        comuna: { type: string }
        ciudad: { type: string }
        ambiente: { $ref: '#/components/schemas/Ambiente' }

    UploadCredentialInput:
      type: object
      required: [pfxBase64, password]
      properties:
        pfxBase64: { type: string, description: "El .pfx en base64." }
        password: { type: string }
        fingerprint: { type: string }
        subjectRut: { type: string }
        expires: { type: string, format: date }

    ApiKeySummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        prefix: { type: string }
        environment: { type: string }
        scopes: { type: array, items: { type: string } }
        scope_emisor_id: { type: [string, "null"] }
        name: { type: [string, "null"] }
        last_used_at: { type: [string, "null"] }
        revoked_at: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    ApiKeyCreated:
      type: object
      description: La key cruda se ve UNA sola vez.
      properties:
        id: { type: string, format: uuid }
        key: { type: string, description: "La key cruda — guárdala." }
        prefix: { type: string }
        environment: { type: string }
        scopes: { type: array, items: { type: string } }
        scopeEmisorId: { type: [string, "null"] }
        name: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        message: { type: string }

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookCreated:
      type: object
      description: El secret se ve UNA sola vez (firma HMAC cada entrega).
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        active: { type: boolean }
        secret: { type: string }
        createdAt: { type: string, format: date-time }
        message: { type: string }

    CreateScheduleInput:
      allOf:
        - $ref: '#/components/schemas/EmitInput'
        - type: object
          required: [cadence, firstRun]
          properties:
            cadence: { type: string, enum: [weekly, biweekly, monthly] }
            firstRun: { type: string, format: date, description: "Primera emisión (no puede ser pasado)." }

    ScheduleCreated:
      type: object
      properties:
        id: { type: string, format: uuid }
        nextRun: { type: string, format: date }
        cadence: { type: string, enum: [weekly, biweekly, monthly] }
        active: { type: boolean }

    ScheduleToggle:
      type: object
      properties:
        id: { type: string, format: uuid }
        active: { type: boolean }

    ScheduledDocument:
      type: object
      properties:
        id: { type: string, format: uuid }
        emisorId: { type: string, format: uuid }
        tipo: { type: integer }
        ambiente: { type: string }
        cadence: { type: string, enum: [weekly, biweekly, monthly] }
        nextRun: { type: string, format: date }
        active: { type: boolean }
        lastRunAt: { type: [string, "null"], format: date-time }
        lastDocumentId: { type: [string, "null"] }
        runCount: { type: integer }
        lastError: { type: [string, "null"] }
        receptorRut: { type: [string, "null"] }
        receptorRazonSocial: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }

    IntercambioInput:
      type: object
      required: [ambiente, envioXml]
      properties:
        ambiente: { $ref: '#/components/schemas/Ambiente' }
        envioXml: { type: string, description: "El XML del EnvioDTE que un tercero te envió." }
        replyTo: { type: string, format: email, description: "Email del emisor para responderle los acuses (prod)." }

    IntercambioResult:
      type: object
      properties:
        id: { type: [string, "null"], format: uuid }
        setId: { type: [string, "null"] }
        dteCount: { type: integer }
        rutEmisor: { type: string }
        estado: { type: string, enum: [acusado] }
        acusesEnviados: { type: boolean }
        enviadoA: { type: [string, "null"] }
        recibosOmitidos: { type: integer }
        acuses:
          type: object
          properties:
            recepcionEnvio: { type: string }
            resultadoDte: { type: string }
            envioRecibos: { type: [string, "null"] }
        archived:
          type: object
          additionalProperties: { type: string }

    PortalMetadata:
      type: object
      properties:
        tipo: { type: integer }
        folio: { type: integer }
        ambiente: { type: string }
        emisor:
          type: [object, "null"]
          properties:
            rut: { type: string }
            razonSocial: { type: string }
        receptor:
          type: object
          properties:
            rut: { type: [string, "null"] }
            razonSocial: { type: [string, "null"] }
        monto: { type: integer }
        xmlUrl: { type: string, format: uri }
        pdfUrl: { type: string, format: uri }
