openapi: 3.1.0
info:
  title: Catálogos SAT — API de consulta, CFDI e integración
  version: "1.0.0"
  description: |
    API REST de los catálogos de claves del SAT (CFDI 4.0), validación y generación
    de CFDI, y gestión de la integración (aplicaciones, API keys y emisores).

    ## Autenticación
    Todas las rutas de `/api/v1` exigen el encabezado `Authorization: Bearer <token>`.
    El token se resuelve a un *principal*: un **usuario** (key personal, sujeto
    `<correo>`) o una **aplicación** (key de máquina, sujeto `app:<slug>`).

    ## Autorización
    El alcance efectivo de una petición es la intersección de:
    1. el **permiso** del sujeto (rol `admin`, `usuario` o `aplicacion`), y
    2. los **scopes** del token, elegidos al crear la API key e inamovibles.

    Toda petición pertenece a una **cuenta maestra** (tenant): el RFC es único
    dentro de la cuenta y puede repetirse en otras. La cuenta sale del token (API)
    o de la sesión (UI), nunca de un parámetro, y un identificador enviado por el
    cliente nunca autoriza por sí solo. Una cuenta sin licencia vigente responde
    402 `licencia_vencida`, y una suspendida 403 `cuenta_suspendida`.

    ## Errores
    Todas las rutas responden el mismo envelope:
    `{"error": {"codigo", "mensaje", "detalles"}}`. `codigo` es estable y es lo que
    debe leer un consumidor. Ver `ErrorEnvelope` y la guía `/docs/integracion`.
  contact:
    name: sat_catalogos
servers:
  # Único entorno público desplegado. Producción aún no está publicada: cuando
  # exista se añadirá aquí su host (previsto cfdi-pro.up-sws.com) y irá primero.
  - url: https://cfdi.up-sws.com
    description: Preproducción
  - url: http://localhost:8080
    description: Desarrollo local

security:
  - bearerAuth: []

tags:
  - name: catalogos
    description: Consulta y descarga de los catálogos de claves del SAT.
  - name: version
    description: Estado de versiones de los catálogos.
  - name: cfdi
    description: Validación, generación y consulta de estado de CFDI 4.0.
  - name: documentos
    description: Documentos CFDI guardados (XML sellado, XML timbrado y PDF).
  - name: empresas
    description: Emisores (CSD) de la cuenta maestra del token.
  - name: aplicaciones
    description: Aplicaciones consumidoras (service accounts).
  - name: apikeys
    description: Gestión de API keys del propio principal.
  - name: cuentas
    description: Cuentas maestras (tenants), su uso y su licencia.

paths:
  /api/v1/catalogos/{catalogo}/entradas:
    get:
      tags: [catalogos]
      summary: Listado paginado y filtrado de un catálogo
      description: >
        Requiere el scope `catalogos:read` y el permiso `catalogo:<nombre>`.
        Además de `clave` y `descripcion`, cada catálogo admite filtros propios
        (pais, estado, municipio, localidad, codigo_postal) documentados en
        `/docs/catalogos/<nombre>`.
      operationId: listarEntradasCatalogo
      parameters:
        - $ref: '#/components/parameters/Catalogo'
        - name: clave
          in: query
          schema: { type: string }
          description: Filtra por clave (exacta o parcial).
        - name: descripcion
          in: query
          schema: { type: string }
          description: Busca en la descripción.
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: per_page
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object } }
                  page: { type: integer }
                  per_page: { type: integer }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

  /api/v1/catalogos/{catalogo}/descarga:
    get:
      tags: [catalogos]
      summary: Descarga completa de un catálogo cacheable
      description: >
        Solo para catálogos pequeños (`Cacheable()` en el registry); los grandes
        responden 400 y se consumen por `/entradas`. Patrón de caché del cliente:
        guardar el catálogo junto con `version_instalada` de `/api/v1/version` y
        volver a descargarlo cuando cambie.
      operationId: descargarCatalogo
      parameters:
        - $ref: '#/components/parameters/Catalogo'
      responses:
        '200':
          description: Catálogo completo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object } }
                  total: { type: integer }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }

  /api/v1/version:
    get:
      tags: [version]
      summary: Estado de versiones de los catálogos
      description: >
        Requiere el scope `actualizaciones:read`. Responde lo último registrado en
        la tabla `actualizaciones`, sin llamar al SAT.
      operationId: obtenerVersion
      responses:
        '200':
          description: Estado de versiones.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version_instalada: { type: [string, 'null'] }
                  version_disponible: { type: [string, 'null'] }
                  ultima_verificacion: { type: [string, 'null'], format: date-time }
                  actualizacion_disponible: { type: boolean }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ScopeInsuficiente' }

  /api/v1/cfdi/validar:
    post:
      tags: [cfdi]
      summary: Valida un CFDI 4.0
      description: >
        Scope `cfdi:validar` + permiso `cfdi:validar`. Cuerpo: el XML crudo
        (máximo 2 MB). Con `?consultar_sat=true` agrega la verificación 17
        (estado del CFDI ante el WS público del SAT).
      operationId: validarCFDI
      parameters:
        - name: consultar_sat
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/xml:
            schema: { type: string }
      responses:
        '200':
          description: Resultado de la validación (valido, errores, advertencias, verificaciones, comprobante).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ResultadoValidacion' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }

  /api/v1/cfdi/consulta-estado:
    post:
      tags: [cfdi]
      summary: Consulta el estado de un CFDI timbrado ante el SAT
      description: Scope `cfdi:consulta` + permiso `cfdi:consulta`.
      operationId: consultarEstadoCFDI
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsultaEstadoInput' }
      responses:
        '200':
          description: Estado del CFDI.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EstadoCFDI' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '422':
          description: El SAT no pudo procesar la consulta (`sat_rechazo`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '502':
          description: Falla de comunicación con el SAT (`sat_no_disponible`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/cfdi/generar/ingreso:
    post:
      tags: [cfdi]
      summary: Genera (y opcionalmente timbra) una factura de ingreso (tipo I)
      description: >
        Scope `cfdi:generar` + permiso `cfdi:generar`. El emisor se resuelve por
        `emisor.rfc` entre las empresas a las que el principal tiene acceso.
        Decodificación estricta: un campo no admitido responde 400. Si el
        documento no pasa la validación, responde 422 con los errores y no sella.
        Con `?timbrar=true` devuelve el XML ya timbrado.
      operationId: generarCFDIIngreso
      parameters:
        - $ref: '#/components/parameters/Timbrar'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FacturaInput' }
      responses:
        '200':
          description: CFDI sellado (o timbrado).
          content:
            application/xml:
              schema: { type: string }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
        '422':
          description: El documento no pasó la validación, o la empresa/agente no están configurados para timbrar.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ResultadoValidacion' }
        '502': { $ref: '#/components/responses/ProveedorError' }

  /api/v1/cfdi/generar/egreso:
    post:
      tags: [cfdi]
      summary: Genera una factura de egreso (tipo E, nota de crédito)
      description: Misma estructura que el ingreso; la nota se liga con `cfdi_relacionados` tipo `01`.
      operationId: generarCFDIEgreso
      parameters:
        - $ref: '#/components/parameters/Timbrar'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FacturaInput' }
      responses:
        '200': { description: CFDI sellado (o timbrado)., content: { application/xml: { schema: { type: string } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
        '422': { description: Validación no superada., content: { application/json: { schema: { $ref: '#/components/schemas/ResultadoValidacion' } } } }
        '502': { $ref: '#/components/responses/ProveedorError' }

  /api/v1/cfdi/generar/traslado:
    post:
      tags: [cfdi]
      summary: Genera un CFDI de traslado (tipo T)
      description: >
        El generador deriva la forma canónica: Moneda XXX, SubTotal y Total 0,
        ObjetoImp `01` por movimiento, sin FormaPago, MetodoPago, Descuento ni
        Impuestos. `documento` solo admite serie, folio, fecha, exportacion y
        lugar_expedicion.
      operationId: generarCFDITraslado
      parameters:
        - $ref: '#/components/parameters/Timbrar'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TrasladoInput' }
      responses:
        '200': { description: CFDI sellado (o timbrado)., content: { application/xml: { schema: { type: string } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
        '422': { description: Validación no superada., content: { application/json: { schema: { $ref: '#/components/schemas/ResultadoValidacion' } } } }
        '502': { $ref: '#/components/responses/ProveedorError' }

  /api/v1/cfdi/generar/pago:
    post:
      tags: [cfdi]
      summary: Genera un recibo de pago (REP, tipo P con complemento Pagos 2.0)
      description: >
        Requiere `pagos` (1..N, cada uno con al menos un `doctos_relacionados`).
        El generador deriva la forma canónica del comprobante y los Totales del
        complemento; los impuestos del complemento se declaran solo por
        `docto_relacionado`.
      operationId: generarCFDIPago
      parameters:
        - $ref: '#/components/parameters/Timbrar'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/REPInput' }
      responses:
        '200': { description: CFDI sellado (o timbrado)., content: { application/xml: { schema: { type: string } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
        '422': { description: Validación o congruencia del complemento no superada., content: { application/json: { schema: { $ref: '#/components/schemas/ResultadoValidacion' } } } }
        '502': { $ref: '#/components/responses/ProveedorError' }

  /api/v1/documentos:
    get:
      tags: [documentos]
      summary: Listado paginado de los documentos CFDI de la cuenta
      description: >
        Scope `documentos:read` + permiso `documento:read`. Lista los metadatos
        de los documentos guardados en el object storage (XML sellado, XML
        timbrado y PDF de representación impresa), del más reciente al más
        antiguo. Solo se ven los de la cuenta maestra del token. El contenido
        se descarga por `/api/v1/documentos/{id}/descarga`.
      operationId: listarDocumentos
      parameters:
        - name: rfc
          in: query
          schema: { type: string }
          description: RFC del emisor.
        - name: uuid
          in: query
          schema: { type: string }
          description: UUID del timbre (folio fiscal).
        - name: tipo
          in: query
          schema: { type: string, enum: [xml_sellado, xml_timbrado, pdf] }
          description: Sin él se listan todos los tipos.
        - name: desde
          in: query
          schema: { type: string }
          description: Fecha inicial, `YYYY-MM-DD` o RFC 3339.
        - name: hasta
          in: query
          schema: { type: string }
          description: Fecha final; con fecha sola incluye todo ese día.
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: per_page
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Listado paginado de metadatos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Documento' }
                  total: { type: integer, format: int64 }
                  page: { type: integer }
                  per_page: { type: integer }
                  total_pages: { type: integer }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }

  /api/v1/documentos/{id}/descarga:
    get:
      tags: [documentos]
      summary: Descarga el contenido de un documento CFDI
      description: >
        Scope `documentos:read` + permiso `documento:read`. La app hace de
        proxy del object storage (que nunca se expone públicamente): responde
        el contenido con su `Content-Type` original y el encabezado
        `Content-Disposition` con el nombre de archivo sugerido (el UUID fiscal
        cuando lo hay). Un id inexistente o de otra cuenta responde 404
        `documento_no_encontrado`.
      operationId: descargarDocumento
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      responses:
        '200':
          description: Contenido del documento.
          headers:
            Content-Disposition:
              schema: { type: string }
              description: 'attachment con el nombre de archivo (p. ej. `filename="4f3c…​.xml"`).'
            X-Content-Sha256:
              schema: { type: string }
              description: Huella SHA-256 del contenido, en hex.
          content:
            application/xml:
              schema: { type: string, format: binary }
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/DocumentoNoEncontrado' }

  /api/v1/empresas:
    get:
      tags: [empresas]
      summary: Emisores a los que tiene acceso el principal
      description: >
        Scope `empresas:read` + permiso `empresa:read`. Nunca devuelve el `.cer`,
        la `.key` ni la contraseña del sello.
      operationId: listarEmpresas
      responses:
        '200':
          description: Listado de emisores accesibles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Empresa' } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ScopeInsuficiente' }
    post:
      tags: [empresas]
      summary: Alta de un emisor con su CSD
      description: >
        Scope `empresas:write` + permiso `empresa:write`. Valida que la contraseña
        descifre la llave, que la llave corresponda al certificado y que esté
        vigente; el RFC y el nombre se extraen del certificado. Concede al que la
        crea el acceso `administrar`. Solo se admite un sello por RFC.
      operationId: crearEmpresa
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaEmpresa' }
      responses:
        '201':
          description: Emisor registrado (encabezado `Location`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Empresa' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ScopeInsuficiente' }
        '409':
          description: Ya existe un sello para ese RFC (`rfc_duplicado`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422':
          description: El CSD o el régimen fiscal no pasaron la validación.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/empresas/{rfc}:
    get:
      tags: [empresas]
      summary: Metadatos de un emisor
      operationId: obtenerEmpresa
      parameters:
        - $ref: '#/components/parameters/RFC'
      responses:
        '200': { description: Emisor., content: { application/json: { schema: { $ref: '#/components/schemas/Empresa' } } } }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
    patch:
      tags: [empresas]
      summary: Edita el régimen fiscal y/o la liga con el agente de timbrado
      description: Requiere el permiso `empresa:write`.
      operationId: actualizarEmpresa
      parameters:
        - $ref: '#/components/parameters/RFC'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CambioEmpresa' }
      responses:
        '200': { description: Emisor actualizado., content: { application/json: { schema: { $ref: '#/components/schemas/Empresa' } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }
        '422': { description: Régimen fiscal inexistente., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } } }
    delete:
      tags: [empresas]
      summary: Borra un emisor de la cuenta
      description: Requiere el permiso `empresa:write`.
      operationId: eliminarEmpresa
      parameters:
        - $ref: '#/components/parameters/RFC'
      responses:
        '204': { description: Borrado. }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }

  /api/v1/empresas/{rfc}/agente/prueba:
    get:
      tags: [empresas]
      summary: Verifica la liga de la empresa con el agente de timbrado
      description: >
        Compara la ruta efectiva (la liga explícita o la derivada del RFC,
        `C:\CompacW\Empresas\<RFC>`) con el listado de empresas del agente.
      operationId: probarAgenteEmpresa
      parameters:
        - $ref: '#/components/parameters/RFC'
      responses:
        '200':
          description: Veredicto de la prueba.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PruebaAgente' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '404': { $ref: '#/components/responses/EmpresaNoEncontrada' }

  /api/v1/aplicaciones:
    get:
      tags: [aplicaciones]
      summary: Lista las aplicaciones registradas
      description: Scope `aplicaciones:read` + permiso `aplicacion:read` (administradores).
      operationId: listarAplicaciones
      responses:
        '200':
          description: Aplicaciones.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Aplicacion' } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
    post:
      tags: [aplicaciones]
      summary: Registra una aplicación consumidora
      description: >
        Scope `aplicaciones:write` + permiso `aplicacion:write`. El usuario
        responsable es el dueño del token que llama. Asigna el rol Casbin
        `aplicacion` al sujeto `app:<slug>`.
      operationId: crearAplicacion
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaAplicacion' }
      responses:
        '201':
          description: Aplicación registrada (encabezado `Location`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Aplicacion' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '409':
          description: Ya existe una aplicación con ese slug (`slug_duplicado`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/aplicaciones/{slug}:
    get:
      tags: [aplicaciones]
      summary: Detalle de una aplicación con sus API keys
      operationId: obtenerAplicacion
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200': { description: Aplicación., content: { application/json: { schema: { $ref: '#/components/schemas/AplicacionConKeys' } } } }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }
    patch:
      tags: [aplicaciones]
      summary: Edita el nombre o el estado de una aplicación
      description: >
        Al desactivarla se retira su rol Casbin y sus API keys dejan de autenticar
        de inmediato; al reactivarla se repone.
      operationId: actualizarAplicacion
      parameters:
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CambioAplicacion' }
      responses:
        '200': { description: Aplicación actualizada., content: { application/json: { schema: { $ref: '#/components/schemas/Aplicacion' } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

  /api/v1/aplicaciones/{slug}/keys:
    post:
      tags: [aplicaciones]
      summary: Crea una API key de la aplicación
      description: >
        Scope `apikeys:manage` + permiso `apikey:manage`. Autorizada por recurso:
        la propia aplicación, o un sujeto con `aplicacion:write`. Anti-escalada:
        con un token solo se otorgan scopes que ese token ya tiene. El token de la
        key se responde una sola vez.
      operationId: crearKeyDeAplicacion
      parameters:
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaAPIKey' }
      responses:
        '201':
          description: API key creada, con su token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKeyConToken' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/EscaladaDeScopes' }
        '404': { $ref: '#/components/responses/NoEncontrado' }
        '409':
          description: La aplicación está inactiva.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/cuentas:
    get:
      tags: [cuentas]
      summary: Lista las cuentas maestras (plataforma)
      description: >
        Scope `cuentas:write` + permiso `cuenta:write`. Devuelve el conteo de
        usuarios y emisores de cada cuenta.
      operationId: listarCuentas
      responses:
        '200':
          description: Cuentas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Cuenta' } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
    post:
      tags: [cuentas]
      summary: Registra una cuenta maestra
      operationId: crearCuenta
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaCuenta' }
      responses:
        '201':
          description: Cuenta registrada (encabezado `Location`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Cuenta' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '409':
          description: Slug u organización de Nova duplicados (`slug_duplicado`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/cuentas/{id}:
    patch:
      tags: [cuentas]
      summary: Edita el nombre, el estado o la organización ligada de una cuenta
      description: >
        Suspender la cuenta (`estado: SUSPENDED`) corta de inmediato a sus
        usuarios y a sus API keys: 403 `cuenta_suspendida`.
      operationId: actualizarCuenta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CambioCuenta' }
      responses:
        '200': { description: Cuenta actualizada., content: { application/json: { schema: { $ref: '#/components/schemas/Cuenta' } } } }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

  /api/v1/cuentas/{id}/usuarios:
    post:
      tags: [cuentas]
      summary: Mueve un usuario a la cuenta (plataforma)
      description: >
        Scope `cuentas:write` + permiso `cuenta:write`. Revoca las sesiones del
        usuario: la cuenta activa se fija al iniciar sesión, así que debe volver a
        entrar. El usuario tiene que existir (se provisiona por JIT en su primer
        login OIDC). Es la forma de montar un tenant a mano mientras Nova no
        provisione las organizaciones.
      operationId: asignarUsuarioACuenta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usuario_email]
              properties:
                usuario_email: { type: string, format: email }
      responses:
        '204': { description: Usuario asignado; sus sesiones quedaron revocadas. }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

  /api/v1/cuentas/{id}/aplicaciones:
    post:
      tags: [cuentas]
      summary: Registra una aplicación en la cuenta (plataforma)
      description: >
        Scope `cuentas:write` + permiso `cuenta:write`. Sin `dueno_email` el
        responsable es quien hace la petición. Asigna el rol Casbin de máquina al
        sujeto `app:<cuenta>:<slug>`.
      operationId: crearAplicacionEnCuenta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nombre, slug]
              properties:
                nombre: { type: string }
                slug:
                  type: string
                  pattern: '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$'
                dueno_email: { type: string, format: email }
      responses:
        '201':
          description: Aplicación registrada en la cuenta (encabezado `Location`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  cuenta_id: { type: integer, format: int64 }
                  nombre: { type: string }
                  slug: { type: string }
                  sujeto: { type: string, example: 'app:2:erp-norte' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }
        '409':
          description: Ya existe una aplicación con ese slug en la cuenta (`slug_duplicado`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /api/v1/cuentas/{id}/aplicaciones/{slug}/keys:
    post:
      tags: [cuentas]
      summary: Crea una API key para una aplicación de la cuenta (plataforma)
      description: >
        Scope `cuentas:write` + permiso `cuenta:write`. La key sale con la cuenta
        de la aplicación, no con la del que llama; el token se responde una sola
        vez. No aplica la regla de anti-escalada porque quien la pide ya tiene el
        permiso de plataforma.
      operationId: crearKeyEnCuenta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaAPIKey' }
      responses:
        '201':
          description: API key creada, con su token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cuenta_id: { type: integer, format: int64 }
                  aplicacion: { type: string }
                  nombre: { type: string }
                  scopes: { type: array, items: { type: string } }
                  token: { type: string, description: Se muestra una sola vez. }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/SinPermiso' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

  /api/v1/cuenta/uso:
    get:
      tags: [cuentas]
      summary: Meterización de uso de la cuenta del token
      description: >
        Scope `cuentas:read` + permiso `cuenta:read`. Contadores por métrica
        (`timbrado`, `validacion`, `generacion`, `consulta_estado`) y periodo
        mensual. Se cuenta el uso; no se aplica ningún cupo.
      operationId: obtenerUsoCuenta
      responses:
        '200':
          description: Contadores de la cuenta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cuenta: { type: string }
                  data: { type: array, items: { $ref: '#/components/schemas/UsoCuenta' } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ScopeInsuficiente' }

  /api/v1/api-keys:
    get:
      tags: [apikeys]
      summary: API keys visibles para el principal que llama
      description: Las de su aplicación si es una key de máquina; las personales si es un usuario.
      operationId: listarAPIKeys
      responses:
        '200':
          description: Keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/APIKey' } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/ScopeInsuficiente' }
    post:
      tags: [apikeys]
      summary: Crea una API key propia
      description: >
        Personal cuando actúa un usuario; de su misma aplicación cuando actúa una
        key de máquina. Crear keys de otra aplicación es
        `POST /api/v1/aplicaciones/{slug}/keys`.
      operationId: crearAPIKey
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AltaAPIKey' }
      responses:
        '201':
          description: API key creada, con su token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKeyConToken' }
        '400': { $ref: '#/components/responses/EntradaInvalida' }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '403': { $ref: '#/components/responses/EscaladaDeScopes' }

  /api/v1/api-keys/{id}/revocar:
    post:
      tags: [apikeys]
      summary: Desactiva una API key propia
      operationId: revocarAPIKey
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      responses:
        '200': { description: Key revocada., content: { application/json: { schema: { $ref: '#/components/schemas/APIKey' } } } }
        '401': { $ref: '#/components/responses/NoAutenticado' }
        '404': { $ref: '#/components/responses/NoEncontrado' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key opaca creada en el panel o por la propia API.

  parameters:
    Catalogo:
      name: catalogo
      in: path
      required: true
      schema: { type: string }
      description: Nombre del catálogo del registry (p. ej. `c_Moneda`).
    RFC:
      name: rfc
      in: path
      required: true
      schema: { type: string }
      description: RFC del emisor (se normaliza a mayúsculas).
    Slug:
      name: slug
      in: path
      required: true
      schema: { type: string }
      description: Slug de la aplicación (`a-z0-9-`).
    Timbrar:
      name: timbrar
      in: query
      schema: { type: boolean, default: false }
      description: >
        Si es true, envía el CFDI sellado al proveedor de timbrado configurado y
        responde el XML timbrado.

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [codigo, mensaje, detalles]
          properties:
            codigo:
              type: string
              description: Código estable del error (p. ej. `empresa_no_encontrada`).
              example: empresa_no_encontrada
            mensaje:
              type: string
              description: Mensaje en español listo para mostrar.
            detalles:
              type: array
              items: { type: string }
              description: Scopes faltantes, campos rechazados o el código del proveedor. Siempre presente.

    Empresa:
      type: object
      description: >
        Metadatos públicos de un emisor. Pertenece a la cuenta maestra del token:
        el RFC es único dentro de la cuenta y puede repetirse en otras. Nunca
        incluye el `.cer`, la `.key` ni la contraseña.
      properties:
        rfc: { type: string, example: EKU9003173C9 }
        nombre: { type: string, example: ESCUELA KEMPER URGATE }
        regimen_fiscal: { type: string, example: "601" }
        no_certificado: { type: string }
        vigencia_inicio: { type: string, format: date-time }
        vigencia_fin: { type: string, format: date-time }
        agente_empresa:
          type: string
          description: Liga con la empresa del agente de timbrado; vacía = se deriva del RFC.

    AltaEmpresa:
      type: object
      required: [cer_b64, key_b64, password, regimen_fiscal]
      properties:
        cer_b64:
          type: string
          description: Certificado `.cer` en base64 (se aceptan saltos de línea).
        key_b64:
          type: string
          description: Llave privada `.key` (PKCS#8 cifrada del SAT) en base64.
        password:
          type: string
          description: Contraseña de la llave privada; se valida que la descifre.
        regimen_fiscal:
          type: string
          description: Clave del catálogo `c_RegimenFiscal`.
        agente_empresa:
          type: string
          description: Opcional; vacía se deriva del RFC.

    CambioEmpresa:
      type: object
      description: Al menos uno de los dos campos.
      properties:
        regimen_fiscal: { type: string }
        agente_empresa:
          type: string
          description: Cadena vacía para quitar la liga explícita.

    PruebaAgente:
      type: object
      properties:
        estado:
          type: string
          enum: [ok, sin_liga, sin_proveedor, no_encontrada, error_agente]
        mensaje: { type: string }
        nombre: { type: string, description: Nombre de la empresa en el agente (estado `ok`). }
        rutas:
          type: array
          items: { type: string }
          description: Hasta 5 rutas del agente (estado `no_encontrada`).
        codigo: { type: integer, description: Código del SDK (estado `error_agente`). }

    Aplicacion:
      type: object
      properties:
        nombre: { type: string, example: AD CFDI }
        slug: { type: string, example: ad-cfdi }
        sujeto:
          type: string
          example: 'app:1:ad-cfdi'
          description: >
            Sujeto de autorización y actor de auditoría: "app:<cuenta>:<slug>".
            El slug es único dentro de la cuenta, así que el sujeto lleva la
            cuenta para no colisionar entre tenants.
        responsable: { type: string, format: email, description: Usuario dueño; si se suspende, las keys dejan de autenticar. }
        activa: { type: boolean }
        created_at: { type: string, format: date-time }

    AplicacionConKeys:
      allOf:
        - $ref: '#/components/schemas/Aplicacion'
        - type: object
          properties:
            keys:
              type: array
              items: { $ref: '#/components/schemas/APIKey' }

    AltaAplicacion:
      type: object
      required: [nombre, slug]
      properties:
        nombre: { type: string }
        slug:
          type: string
          pattern: '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$'
          description: Minúsculas, dígitos y guiones; sin guiones en los extremos.

    CambioAplicacion:
      type: object
      properties:
        nombre: { type: string }
        activa: { type: boolean }

    APIKey:
      type: object
      properties:
        id: { type: integer, format: int64 }
        nombre: { type: string }
        activa: { type: boolean }
        scopes: { type: array, items: { type: string } }
        expira_en: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time }
        aplicacion_slug: { type: string, description: Vacío en keys personales. }

    APIKeyConToken:
      allOf:
        - $ref: '#/components/schemas/APIKey'
        - type: object
          properties:
            token:
              type: string
              description: Token plano; se devuelve una sola vez y no se puede volver a consultar.

    AltaAPIKey:
      type: object
      required: [nombre, scopes]
      properties:
        nombre: { type: string }
        scopes:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - catalogos:read
              - actualizaciones:read
              - cfdi:validar
              - cfdi:generar
              - cfdi:consulta
              - documentos:read
              - empresas:read
              - empresas:write
              - aplicaciones:read
              - aplicaciones:write
              - apikeys:manage
        expira_en: { type: string, format: date-time, description: Opcional; sin ella la key no caduca. }

    Cuenta:
      type: object
      properties:
        id: { type: integer, format: int64 }
        nombre: { type: string }
        slug: { type: string, example: grupo-norte }
        externa_id:
          type: string
          description: >
            `organizations.id` de Nova Core: es el `owner_id` con el que Nova
            License identifica la licencia de esta plataforma.
        estado: { type: string, enum: [ACTIVE, SUSPENDED] }
        usuarios: { type: integer, format: int64 }
        empresas: { type: integer, format: int64, description: Emisores (RFC) de la cuenta. }
        created_at: { type: string, format: date-time }

    AltaCuenta:
      type: object
      required: [nombre, slug]
      properties:
        nombre: { type: string }
        slug:
          type: string
          pattern: '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$'
        externa_id: { type: string, description: Opcional; liga la cuenta con la organización de Nova. }

    CambioCuenta:
      type: object
      properties:
        nombre: { type: string }
        estado: { type: string, enum: [ACTIVE, SUSPENDED] }
        externa_id: { type: string }

    UsoCuenta:
      type: object
      properties:
        metrica:
          type: string
          enum: [timbrado, validacion, generacion, consulta_estado]
        periodo: { type: string, example: '2026-10', description: Ventana mensual de agregación. }
        cantidad: { type: integer, format: int64 }

    Documento:
      type: object
      description: >
        Metadatos públicos de un documento CFDI guardado en el object storage.
        Pertenece a la cuenta maestra del token; la clave del objeto nunca se
        expone (la descarga siempre pasa por el endpoint, que hace de proxy).
      properties:
        id: { type: integer, format: int64 }
        tipo: { type: string, enum: [xml_sellado, xml_timbrado, pdf] }
        uuid: { type: string, description: Folio fiscal; vacío en los XML sellados sin timbrar. }
        rfc_emisor: { type: string, example: EKU9003173C9 }
        content_type: { type: string, enum: [application/xml, application/pdf] }
        bytes: { type: integer, format: int64 }
        sha256: { type: string, description: Huella SHA-256 del contenido, en hex. }
        created_at: { type: string, format: date-time }

    ConsultaEstadoInput:
      type: object
      required: [uuid, rfc_emisor, rfc_receptor, total]
      properties:
        uuid: { type: string }
        rfc_emisor: { type: string }
        rfc_receptor: { type: string }
        total: { type: string }

    EstadoCFDI:
      type: object
      properties:
        uuid: { type: string }
        rfc_emisor: { type: string }
        rfc_receptor: { type: string }
        total: { type: string }
        codigo_estatus: { type: string }
        es_cancelable: { type: string }
        estado: { type: string, enum: [Vigente, Cancelado, No Encontrado] }
        estatus_cancelacion: { type: string }
        validacion_efos: { type: string }

    FacturaInput:
      type: object
      description: Estructura de los tipos I y E; el tipo lo fija el endpoint.
      required: [emisor, receptor, documento, movimientos]
      properties:
        emisor:
          type: object
          required: [rfc]
          properties:
            rfc: { type: string, description: El resto de los datos del emisor sale de la empresa registrada. }
        receptor:
          type: object
          required: [rfc, domicilio_fiscal_receptor, regimen_fiscal_receptor, uso_cfdi]
          properties:
            rfc: { type: string }
            nombre: { type: string }
            domicilio_fiscal_receptor: { type: string }
            regimen_fiscal_receptor: { type: string }
            uso_cfdi: { type: string }
        documento:
          type: object
          description: Serie, folio, fecha, forma_pago, moneda, tipo_cambio, metodo_pago, exportacion, lugar_expedicion, sub_total, descuento, total, impuestos y cfdi_relacionados.
          additionalProperties: true
        movimientos:
          type: array
          minItems: 1
          items:
            type: object
            description: clave_prod_serv, cantidad, clave_unidad, unidad, descripcion, valor_unitario, importe, descuento, objeto_imp e impuestos.
            additionalProperties: true

    TrasladoInput:
      type: object
      required: [emisor, receptor, documento, movimientos]
      properties:
        emisor: { type: object, properties: { rfc: { type: string } } }
        receptor: { type: object, additionalProperties: true }
        documento:
          type: object
          description: Solo serie, folio, fecha, exportacion y lugar_expedicion; el resto lo deriva el generador.
          additionalProperties: true
        movimientos: { type: array, items: { type: object, additionalProperties: true } }

    REPInput:
      type: object
      required: [emisor, receptor, documento, pagos]
      properties:
        emisor: { type: object, properties: { rfc: { type: string } } }
        receptor: { type: object, additionalProperties: true }
        documento:
          type: object
          description: Solo serie, folio, fecha, exportacion y lugar_expedicion.
          additionalProperties: true
        pagos:
          type: array
          minItems: 1
          items:
            type: object
            description: >
              fecha_pago, forma_de_pago_p, moneda_p, tipo_cambio_p, monto y
              doctos_relacionados (con sus impuestos opcionales). El generador
              deriva ObjetoImpDR, ImporteDR, ImpuestosP y los Total* de Totales.
            additionalProperties: true

    ResultadoValidacion:
      type: object
      properties:
        valido: { type: boolean }
        errores:
          type: array
          items:
            type: object
            properties:
              codigo: { type: string }
              campo: { type: string }
              valor: { type: string }
              catalogo: { type: string }
              mensaje: { type: string }
        advertencias: { type: array, items: { type: string } }
        verificaciones:
          type: array
          items:
            type: object
            description: Checklist de las 17 verificaciones permanentes con su estatus y las reglas CFDI40xxx aplicables.
            additionalProperties: true
        comprobante:
          type: object
          description: Encabezado del documento (fecha, serie, folio, tipo, emisor, uuid).
          additionalProperties: true

  responses:
    NoAutenticado:
      description: Falta el token, o no es válido/activo/vigente (`token_ausente`, `token_invalido`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ScopeInsuficiente:
      description: El token no tiene los scopes del endpoint (`scope_insuficiente`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    SinPermiso:
      description: El sujeto no tiene el permiso Casbin del endpoint (`permiso_insuficiente`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    AccesoDenegado:
      description: >
        El recurso no es gestionable por quien llama (`acceso_denegado`): una
        aplicación solo gestiona las API keys de sí misma.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    EscaladaDeScopes:
      description: Se pidieron scopes que el token que llama no tiene (`escalada_de_scopes`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    EntradaInvalida:
      description: Cuerpo inválido, campos no admitidos o dato obligatorio ausente (`entrada_invalida`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NoEncontrado:
      description: El recurso no existe (`recurso_no_encontrado`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    EmpresaNoEncontrada:
      description: No hay emisor con ese RFC o el principal no tiene acceso (`empresa_no_encontrada`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    DocumentoNoEncontrado:
      description: El documento no existe o pertenece a otra cuenta (`documento_no_encontrado`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    SujetoNoEncontrado:
      description: No existe el usuario o la aplicación del acceso (`recurso_no_encontrado`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ProveedorError:
      description: El proveedor de timbrado rechazó la operación (`proveedor_error`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    CuentaSuspendida:
      description: La cuenta maestra está suspendida (`cuenta_suspendida`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    LicenciaNoVigente:
      description: La licencia de la cuenta expiró o no se pudo confirmar (`licencia_vencida`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
