openapi: 3.1.0
info:
  title: tagger API
  description: |
    Servicio de categorización automática de movimientos bancarios.
    Pipeline en cascada: catálogo → patrones → MCC → IA fallback.
  version: 1.0.0
  contact:
    name: Mango Apps Paraguay

servers:
  - url: http://localhost:3000
    description: Local dev
  - url: https://tagger.mango.internal
    description: Producción (placeholder)

security:
  - ApiKeyAuth: []

paths:
  /health:
    get:
      tags: [Health]
      summary: Liveness probe
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]

  /health/ready:
    get:
      tags: [Health]
      summary: Readiness probe (DB + LLM)
      security: []
      responses:
        '200':
          description: Estado servicios dependientes
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok, degraded] }
                  db: { type: boolean }
                  llm:
                    type: string
                    enum: [ok, fail, skip]
                    description: 'Probe LLM (OpenRouter). `skip` cuando IA_ENABLED=false o no hay API key.'

  /categorizar-movimiento:
    post:
      tags: [Categorización]
      summary: Categoriza un movimiento (endpoint principal)
      description: |
        Cascada: catálogo (bancard_id+codigo) → patrones → MCC → IA fallback async.
        Si todas las capas sync devuelven null, response inmediato es `categoria_id: null`
        y se dispara IA fallback (OpenRouter) en background. Cliente debe poll `/movimientos/:id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategorizarRequest'
            examples:
              completo:
                value:
                  descripcion: 'BURGER KING SHOPPING DEL SOL'
                  bancard_id: '12345'
                  codigo_comercio: '999'
                  mcc: '5812'
                  monto: 45000
                  origen: 'mobile'
              minimo:
                value:
                  descripcion: 'PETROBRAS RUTA 2'
      responses:
        '200':
          description: Movimiento categorizado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategorizarResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /movimientos/{id}:
    get:
      tags: [Categorización]
      summary: Detalle movimiento (incluye resultado de IA si ya terminó)
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Detalle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MovimientoDetalle'
        '404':
          $ref: '#/components/responses/NotFound'

  /movimientos/{id}/reprocesar:
    post:
      tags: [Categorización]
      summary: Re-ejecuta cascada + IA fallback sobre un movimiento existente
      description: |
        Útil para:
        - Movimientos viejos sin categoría (creados antes de agregar patrones/MCCs).
        - Movimientos donde IA falló al momento de POST inicial.
        - Mobile permite "re-categorizar este movimiento".

        Reconstruye `MovimientoInput` con los campos almacenados, ejecuta cascada,
        actualiza prediccion + evidencia. Si cascada agotada, dispara IA async
        (cliente debe poll `/movimientos/:id`).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                bypass_catalogo:
                  type: boolean
                  description: Testing — salta capa catálogo, fuerza cascada pura
      responses:
        '200':
          description: Movimiento reprocesado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CategorizarResponse'
                  - type: object
                    properties:
                      ia_disparada:
                        type: boolean
                        description: true si cascada agotada y se disparó IA async
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /movimientos/{id}/correccion:
    post:
      tags: [Categorización]
      summary: Corrige manualmente la categoría asignada
      description: |
        Por default crea una regla user-scope (prioridad 1) que aprende para próximas
        categorizaciones del mismo nombre. Pasar `aprender: false` para tratar el
        movimiento como excepción puntual sin contaminar la memoria del usuario.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [categoria_id_nueva]
              properties:
                categoria_id_nueva:
                  type: string
                  format: uuid
                  description: UUID de la categoría correcta (obtener via GET /categorias)
                motivo:
                  type: string
                  maxLength: 500
                  example: 'era taxi, no restaurante'
                usuario:
                  type: string
                  maxLength: 120
                  description: Id del usuario que corrige. Determina el scope de la regla aprendida.
                aprender:
                  type: boolean
                  default: true
                  description: |
                    Si `true` (default), crea/actualiza regla user-scope con prio 1 para que
                    próximos movs del mismo nombre devuelvan la categoría corregida automático.

                    Si `false`, sólo se modifica este movimiento. La memoria del usuario no se
                    altera. Útil para excepciones puntuales (ej estación de servicio donde
                    sólo compraste algo del shop). El registro en `correcciones_usuario` (audit)
                    se inserta siempre, así que las sugerencias cross-user siguen funcionando.
                subcategoria_usuario_id:
                  type: string
                  format: uuid
                  description: |
                    Opcional. Id de subcategoría personal del usuario. Si se provee:
                    - Backend valida que pertenezca al `usuario` indicado.
                    - Resuelve canónica padre y la usa como `categoria_id_nueva` efectiva
                      (override silencioso).
                    - Persiste `subcategoria_usuario_id` en el mov y en la corrección.
                    Regla aprendida (cuando `aprender=true`) sigue apuntando a la canónica
                    (V1: rule engine no maneja subcats; V2 abrirá esta puerta).
      responses:
        '200':
          description: Corrección aplicada
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  correccion_id: { type: string, format: uuid }
                  categoria_anterior_id: { type: string, format: uuid, nullable: true }
                  categoria_anterior: { type: string, nullable: true }
                  categoria_nueva_id: { type: string, format: uuid }
                  categoria_nueva: { type: string }
        '404':
          $ref: '#/components/responses/NotFound'

  /categorias:
    get:
      tags: [Recursos]
      summary: Lista categorías disponibles (para dropdowns UI)
      responses:
        '200':
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Categoria'


  /reglas:
    get:
      tags: [Reglas]
      summary: Listar reglas por scope
      description: |
        Devuelve reglas (tipo `literal`, `contiene` o `regex`) filtradas por scope.
        Scope puede ser `global` o `usuario:<nombre>`.
      parameters:
        - in: query
          name: scope
          required: true
          schema: { type: string, example: 'usuario:usuario_42' }
      responses:
        '200':
          description: Lista
          content:
            application/json:
              schema:
                type: object
                properties:
                  scope: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        scope: { type: string }
                        tipo: { type: string, enum: [literal, contiene, regex] }
                        valor: { type: string }
                        valor_normalizado: { type: string }
                        categoria_slug: { type: string }
                        prioridad: { type: integer }
                        activo: { type: boolean }
                        hits: { type: integer }
                        origen: { type: string, example: manual }
                        descripcion: { type: string, nullable: true }
                        created_at: { type: string, format: date-time }
                        updated_at: { type: string, format: date-time }
        '400': { description: scope_requerido }
    post:
      tags: [Reglas]
      summary: Crear regla
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope, tipo, valor, categoria_slug]
              properties:
                scope: { type: string, example: 'usuario:usuario_42' }
                tipo: { type: string, enum: [literal, contiene, regex] }
                valor: { type: string, minLength: 2, maxLength: 500 }
                categoria_slug: { type: string }
                prioridad: { type: integer, minimum: 1, maximum: 10000 }
                descripcion: { type: string, nullable: true }
                origen: { type: string, example: manual }
      responses:
        '201': { description: Creada }
        '400': { description: payload_invalido | regex_invalido | valor_muy_corto | categoria_inexistente }
        '409': { description: regla_duplicada }
    delete:
      tags: [Reglas]
      summary: Eliminar regla por scope + valor (alternativa a DELETE /reglas/:id)
      parameters:
        - in: query
          name: scope
          required: true
          schema: { type: string }
        - in: query
          name: valor
          required: true
          schema: { type: string }
      responses:
        '204': { description: Eliminada }
        '400': { description: scope_y_valor_requeridos | valor_invalido }
        '404': { description: no_existe }

  /reglas/sugerencias:
    get:
      tags: [Reglas]
      summary: Sugerencias de reglas user-scope basadas en correcciones repetidas
      description: |
        Agrupa correcciones del usuario por `nombre_normalizado + categoría`. Devuelve grupos
        donde `veces >= umbral` (default 2). Excluye los nombres con regla activa existente.
      parameters:
        - in: query
          name: usuario
          required: true
          schema: { type: string }
        - in: query
          name: umbral
          schema: { type: integer, minimum: 1, default: 2 }
      responses:
        '200':
          description: Sugerencias
          content:
            application/json:
              schema:
                type: object
                properties:
                  usuario: { type: string }
                  umbral: { type: integer }
                  sugerencias:
                    type: array
                    items:
                      type: object
                      properties:
                        scope: { type: string }
                        valor: { type: string }
                        valorNormalizado: { type: string }
                        categoriaSlug: { type: string }
                        categoriaNombre: { type: string }
                        veces: { type: integer }
                        ejemplos: { type: array, items: { type: string } }

  /descripciones/sugerencias:
    get:
      tags: [Descripciones]
      summary: Autocomplete per-user de descripciones
      description: |
        Devuelve top-K descripciones que ESE usuario tipeó antes y empiezan con
        `q`. Ordenadas por frecuencia descendente. Opcional `categoria_id`
        boostea las descripciones cuya categoría top coincide.

        Scope estricto per-user: nunca devuelve descripciones de otros usuarios.
        Latencia objetivo: p99 < 50ms (lookup btree prefix).
      parameters:
        - in: query
          name: usuario
          required: true
          schema: { type: string }
          description: Id del usuario final (mismo que `origen` en categorizar).
        - in: query
          name: q
          required: true
          schema: { type: string, minLength: 2, maxLength: 200 }
          description: Prefix a buscar (mín 2 chars).
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
        - in: query
          name: categoria_id
          schema: { type: string, format: uuid }
          description: Opcional. Boost ranking de descripciones cuya cat top coincide.
      responses:
        '200':
          description: Sugerencias
          content:
            application/json:
              schema:
                type: object
                properties:
                  usuario: { type: string }
                  q: { type: string }
                  limit: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        descripcion: { type: string }
                        freq: { type: integer }
                        categoria_slug: { type: string }

  /categorias-usuario:
    get:
      tags: [Categorías personales]
      summary: Lista subcategorías personales activas del usuario
      description: |
        Devuelve subcategorías user-scope del usuario indicado, con datos de la
        canónica padre (rubro). Modelo: el user crea cats personales (ej "Streaming",
        "Verdulería del barrio") y la app las ancla a un rubro global ("Entretenimiento",
        "Alimentación"). Reports internos siguen agrupando por canónica.
      parameters:
        - in: query
          name: usuario
          required: true
          schema: { type: string, minLength: 1, maxLength: 120 }
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/CategoriaUsuario' }
        '400':
          $ref: '#/components/responses/BadRequest'
    post:
      tags: [Categorías personales]
      summary: Crear subcategoría personal anclada a un rubro canónico
      description: |
        Validaciones:
        - `canonica_id` debe apuntar a categoría activa y no reemplazada.
        - `nombre` no puede ser igual al de la canónica padre.
        - Slug auto-generado del nombre si no se provee; único per usuario.
        - Cap 200 subcats por usuario.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usuario, canonica_id, nombre]
              properties:
                usuario: { type: string, minLength: 1, maxLength: 120 }
                canonica_id: { type: string, format: uuid }
                nombre: { type: string, minLength: 1, maxLength: 80, example: Streaming }
                slug:
                  type: string
                  pattern: '^[a-z0-9-]+$'
                  maxLength: 60
                  description: Opcional; default = slugify(nombre).
                emoji: { type: string, maxLength: 8, nullable: true, example: '🎬' }
                color: { type: string, maxLength: 20, nullable: true }
      responses:
        '201':
          description: Creada
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CategoriaUsuario' }
        '400':
          description: Validación falló o canónica inactiva/reemplazada o nombre = canon
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: slug_duplicado per usuario
        '429':
          description: cap_alcanzado (>= 200 subcats activas)

  /categorias-usuario/{id}:
    patch:
      tags: [Categorías personales]
      summary: Editar nombre/emoji/color/activo
      description: |
        No se permite cambiar `usuario_id` ni `canonica_id`. Para mover una
        subcat a otro rubro, hay que borrarla y recrear.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                nombre: { type: string, minLength: 1, maxLength: 80 }
                emoji: { type: string, maxLength: 8, nullable: true }
                color: { type: string, maxLength: 20, nullable: true }
                activo:
                  type: boolean
                  description: false = soft hide (no se muestra en listados, mov no se pierde)
      responses:
        '200':
          description: Actualizada
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CategoriaUsuario' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Categorías personales]
      summary: Hard delete subcategoría
      description: |
        Borra fila. FK `movimientos.subcategoria_usuario_id ON DELETE SET NULL`:
        movs que la usaban quedan sólo con la canónica padre, sin perder historial.
        Alternativa: PATCH `activo=false` para hide sin borrar.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Eliminada
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean, example: true } }
        '404':
          $ref: '#/components/responses/NotFound'

  /movimientos:
    get:
      tags: [Categorización]
      summary: Lista movimientos paginados
      description: |
        Ordenados por `created_at desc`. Incluye `categoria` embebida (confirmada
        si existe, sino predicha) + `subcategoria` poblada cuando aplica.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
        - in: query
          name: origen
          schema: { type: string, minLength: 1, maxLength: 200 }
          description: Filtro por origen (ej `demo`, `mobile`, `api`).
      responses:
        '200':
          description: Items paginados
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/MovimientoDetalle' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }

  /movimientos/importar:
    post:
      tags: [Importación]
      summary: Importa rows a movimientos ejecutando cascada async
      description: Devuelve `importId` inmediato. Worker async corre cascada por row. Status via `/movimientos/importar/status`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items:
                    type: object
                    properties:
                      nombre: { type: string }
                      mcc: { type: string }
                      monto: { type: string }
                batchId: { type: string }
                bypass_catalogo: { type: boolean }
      responses:
        '202':
          description: Aceptado para procesamiento async
          content:
            application/json:
              schema:
                type: object
                properties:
                  importId: { type: string }
                  batchId: { type: string, nullable: true }

  /movimientos/importar/status:
    get:
      tags: [Importación]
      summary: Estado del import en curso
      responses:
        '200':
          description: Status
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /movimientos/{id}/categorias-sugeridas:
    get:
      tags: [Categorización]
      summary: Top-K categorías candidatas (similitud trigram)
      description: |
        Devuelve ranking de categorías similares al texto enriquecido del mov
        (o al `q` provisto). Útil para UI "¿quisiste decir X?" antes de
        aplicar corrección.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: q
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 5 }
        - in: query
          name: offset
          schema: { type: integer, default: 0 }
        - in: query
          name: umbral
          schema: { type: number, default: 0 }
      responses:
        '200':
          description: Sugerencias
          content:
            application/json:
              schema:
                type: object
                properties:
                  categoria_origen_id: { type: string, format: uuid, nullable: true }
                  limit: { type: integer }
                  offset: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        slug: { type: string }
                        nombre: { type: string }
                        descripcion: { type: string, nullable: true }
                        similitud: { type: number }

  /categorias/{identificador}/similares:
    get:
      tags: [Categorías]
      summary: Categorías similares a la dada (trigram)
      description: |
        `identificador` acepta UUID, slug actual o alias antiguo.
      parameters:
        - in: path
          name: identificador
          required: true
          schema: { type: string }
        - in: query
          name: q
          schema: { type: string }
          description: Si presente, busca contra este texto en vez del de la cat origen.
        - in: query
          name: limit
          schema: { type: integer, default: 5 }
        - in: query
          name: offset
          schema: { type: integer, default: 0 }
        - in: query
          name: umbral
          schema: { type: number, default: 0 }
      responses:
        '200':
          description: Ranking
          content:
            application/json:
              schema:
                type: object
                properties:
                  categoria_origen:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      slug: { type: string }
                      nombre: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true

  /categorias/{identificador}/usage:
    get:
      tags: [Categorías]
      summary: Counts de referencias en otras tablas
      parameters:
        - in: path
          name: identificador
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  movimientos: { type: integer }
                  mcc: { type: integer }
                  comercios: { type: integer }

  /reglas/sugerencias-globales:
    get:
      tags: [Reglas]
      summary: Patrones que varios usuarios distintos corrigieron a la misma cat
      description: |
        Candidatos a regla global. Excluye los que ya tienen regla global activa.
      parameters:
        - in: query
          name: min_usuarios
          schema: { type: integer, default: 3 }
        - in: query
          name: min_total
          schema: { type: integer, default: 5 }
      responses:
        '200':
          description: Sugerencias
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true

  /mcc:
    get:
      tags: [MCC]
      summary: Lista catálogo MCC
      parameters:
        - in: query
          name: categoria
          schema: { type: string }
        - in: query
          name: sin_categoria
          schema: { type: boolean }
      responses:
        '200':
          description: MCCs
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { type: object, additionalProperties: true } }
    post:
      tags: [MCC]
      summary: Alta MCC
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cod_mcc, descripcion]
              properties:
                cod_mcc: { type: string, pattern: '^\d{2,4}$' }
                descripcion: { type: string }
                categoria_slug: { type: string }
                ambiguo: { type: boolean }
      responses:
        '201': { description: Creado }

  /mcc/{cod_mcc}:
    patch:
      tags: [MCC]
      summary: Editar MCC
      parameters:
        - in: path
          name: cod_mcc
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                descripcion: { type: string }
                categoria_slug: { type: string }
                ambiguo: { type: boolean }
      responses:
        '200': { description: Actualizado }
        '404': { description: no_existe }
    delete:
      tags: [MCC]
      summary: Eliminar MCC
      parameters:
        - in: path
          name: cod_mcc
          required: true
          schema: { type: string }
      responses:
        '204': { description: Eliminado }

  /marcas:
    get:
      tags: [Marcas]
      summary: Lista marcas conocidas
      parameters:
        - in: query
          name: categoria
          schema: { type: string }
      responses:
        '200': { description: Marcas, content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { type: object, additionalProperties: true } } } } } } }
    post:
      tags: [Marcas]
      summary: Alta marca
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [marca, categoria_slug]
              properties:
                marca: { type: string }
                categoria_slug: { type: string }
                descripcion: { type: string }
      responses:
        '201': { description: Creada }

  /marcas/{id}:
    patch:
      tags: [Marcas]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { '200': { description: Actualizada } }
    delete:
      tags: [Marcas]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { '204': { description: Eliminada } }

  /comercios:
    get:
      tags: [Comercios]
      summary: Lista entradas de mcc_por_nombre
      parameters:
        - { in: query, name: categoria, schema: { type: string } }
        - { in: query, name: q, schema: { type: string } }
        - { in: query, name: rev_only, schema: { type: boolean } }
        - { in: query, name: limit, schema: { type: integer, default: 50 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { type: object, additionalProperties: true } }
                  total: { type: integer }

  /comercios/{id}:
    patch:
      tags: [Comercios]
      summary: Reasignar categoría o cambiar requiere_revision
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                categoria_slug: { type: string }
                requiere_revision: { type: boolean }
      responses:
        '200': { description: Actualizado }
        '404': { description: no_existe }

  /catalogo/importar:
    post:
      tags: [Importación]
      summary: Importa rows a mcc_por_nombre (chunked, async)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items: { type: object, additionalProperties: true }
                correr_cascada: { type: boolean }
      responses:
        '202': { description: Aceptado }

  /catalogo/importar/status:
    get:
      tags: [Importación]
      summary: Estado import catálogo
      responses:
        '200': { description: Status, content: { application/json: { schema: { type: object, additionalProperties: true } } } }

  /test-batch/start:
    post:
      tags: [Test Batch]
      summary: Dispara worker test masivo in-process
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [batchId]
              properties:
                batchId: { type: string }
                source: { type: string }
                limit: { type: integer }
                concurrency: { type: integer }
                bypassCatalogo: { type: boolean }
      responses:
        '200': { description: OK }

  /test-batch/stop:
    post:
      tags: [Test Batch]
      summary: Cancela batch en curso
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { batch_id: { type: string } }
      responses:
        '200': { description: OK }

  /test-batch/list:
    get:
      tags: [Test Batch]
      summary: Batches activos
      responses:
        '200': { description: List, content: { application/json: { schema: { type: array, items: { type: object, additionalProperties: true } } } } }

  /test-batch/{batch_id}/stats:
    get:
      tags: [Test Batch]
      summary: Stats agregadas del batch
      parameters: [{ in: path, name: batch_id, required: true, schema: { type: string } }]
      responses:
        '200': { description: Stats, content: { application/json: { schema: { type: object, additionalProperties: true } } } }

  /test-batch/{batch_id}/agreement:
    get:
      tags: [Test Batch]
      summary: Agreement pipeline vs categoria_xlsx ground truth
      parameters:
        - { in: path, name: batch_id, required: true, schema: { type: string } }
        - { in: query, name: ground_truth, schema: { type: string } }
      responses:
        '200': { description: Agreement, content: { application/json: { schema: { type: object, additionalProperties: true } } } }

  /test-batch/{batch_id}/agreement-mcc:
    get:
      tags: [Test Batch]
      summary: Agreement vs MCC catalog
      parameters:
        - { in: path, name: batch_id, required: true, schema: { type: string } }
        - { in: query, name: reference, schema: { type: string, enum: [mcc, combined_mcc] } }
        - { in: query, name: include_ambiguo, schema: { type: boolean } }
        - { in: query, name: include_generic, schema: { type: boolean } }
      responses:
        '200': { description: Agreement, content: { application/json: { schema: { type: object, additionalProperties: true } } } }

  /test-batch/{batch_id}/analisis:
    get:
      tags: [Test Batch]
      summary: Análisis profundo
      description: Distribución por fuente/categoría, patrones más usados, top sin-predicción, latencia p50/p95/p99, cobertura por decil.
      parameters:
        - { in: path, name: batch_id, required: true, schema: { type: string } }
        - { in: query, name: ground_truth, schema: { type: string } }
      responses:
        '200': { description: Análisis, content: { application/json: { schema: { type: object, additionalProperties: true } } } }

  /presupuestos:
    get:
      tags: [Presupuestos]
      summary: Lista presupuestos vigentes del usuario
      parameters:
        - { in: query, name: usuario, required: true, schema: { type: string, minLength: 1, maxLength: 120 } }
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Presupuesto' }
    post:
      tags: [Presupuestos]
      summary: Crear presupuesto mensual para una categoría
      description: |
        Modelo versionado: si ya existe vigente con monto > 0 para (usuario, categoria),
        devuelve 409. Para cambiar el monto, usar PATCH (no DELETE+POST).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usuario, categoria_id, monto_mensual]
              properties:
                usuario: { type: string, minLength: 1, maxLength: 120 }
                categoria_id: { type: string, format: uuid }
                monto_mensual: { type: number, exclusiveMinimum: 0, maximum: 1000000000 }
      responses:
        '201':
          description: Creado
          content: { application/json: { schema: { $ref: '#/components/schemas/Presupuesto' } } }
        '404': { description: categoria_no_encontrada }
        '409': { description: presupuesto_ya_existe }

  /presupuestos/{id}:
    patch:
      tags: [Presupuestos]
      summary: Editar monto = INSERT nueva versión vigente hoy
      description: Preserva histórico de topes para reports pasados.
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [monto_mensual]
              properties:
                monto_mensual: { type: number, exclusiveMinimum: 0 }
      responses:
        '200': { description: OK }
        '404': { description: no_encontrado }
    delete:
      tags: [Presupuestos]
      summary: Baja = INSERT versión con monto=0
      description: No borra fila; preserva histórico. Para re-alta, POST de nuevo.
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200': { description: OK }
        '404': { description: no_encontrado }

  /presupuestos/estado:
    get:
      tags: [Presupuestos]
      summary: Estado combinado (presupuesto + gastado del mes) por categoría
      parameters:
        - { in: query, name: usuario, required: true, schema: { type: string } }
        - in: query
          name: mes
          schema: { type: string, pattern: '^\d{4}-\d{2}$' }
          description: 'Default: mes actual.'
      responses:
        '200':
          description: Estado
          content:
            application/json:
              schema:
                type: object
                properties:
                  mes: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true

  /chat:
    post:
      tags: [Chat]
      summary: Chat IA contextual con movimientos del usuario
      description: |
        Proxy a OpenRouter (modelos free, fallback chain). Backend monta prompt
        con resumen de movs + historial conversacional. Si `OPENROUTER_API_KEY`
        no está seteado en server, devuelve 503.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages, movs, usuario]
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role: { type: string, enum: [user, assistant, system] }
                      content: { type: string }
                movs:
                  type: array
                  description: Contexto de movimientos (top ~60 últimos).
                  items:
                    type: object
                    properties:
                      id: { type: string }
                      nombre: { type: string }
                      monto: { type: number }
                      fecha: { type: string }
                      categoria: { type: string, nullable: true }
                usuario: { type: string }
      responses:
        '200':
          description: Respuesta IA
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
        '503': { description: openrouter_no_configurado }

  /mia-agent:
    post:
      tags: [MIA]
      summary: Agente MIA combinado (legacy) — ENTENDER + REDACTAR en una llamada
      description: |
        Pipeline conversacional: clasifica intención (tool-call) → usa los `hechos`
        que calculó el front (grounding) → redacta. Devuelve `{ text, intent, widget,
        action, model }`. El front moderno usa `/mia/entender` + `/mia/redactar`
        (LlmPort de `@mango/mia-core`); este endpoint queda por compatibilidad.
        503 si no hay key de LLM (`OPENAI_API_KEY`/`OPENROUTER_API_KEY`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role: { type: string, enum: [user, assistant, system] }
                      content: { type: string }
                movs:
                  type: array
                  description: Contexto de movimientos (top ~60 últimos).
                  items:
                    type: object
                    properties:
                      id: { type: string }
                      nombre: { type: string }
                      monto: { type: number }
                      fecha: { type: string }
                      categoria: { type: string, nullable: true }
                usuario: { type: string }
                contexto: { type: string }
                hechos: { type: string, description: 'Datos reales calculados por el front (grounding del redactor).' }
                conTarjeta: { type: boolean, description: 'Si el front mostrará una tarjeta (define el estilo del texto).' }
      responses:
        '200':
          description: Respuesta del agente
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
                  intent: { type: string }
                  widget: { type: object, nullable: true }
                  action: { type: object, nullable: true }
                  model: { type: string }
        '503': { description: llm_no_configurado }

  /mia/entender:
    post:
      tags: [MIA]
      summary: 'LlmPort · paso ENTENDER — clasifica el mensaje (intención + acción + slots)'
      description: |
        Paso 1 del orquestador (`@mango/mia-core`). Tool-call forzado: clasifica el
        último mensaje (resuelve referencias del historial). NO toca datos ni redacta.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role: { type: string, enum: [user, assistant, system] }
                      content: { type: string }
      responses:
        '200':
          description: Clasificación estructurada (EntenderResult)
          content:
            application/json:
              schema:
                type: object
                properties:
                  intent: { type: string, enum: [consulta, analitica, accion, conversacion] }
                  widget: { type: string }
                  categoria: { type: string }
                  comercio: { type: string }
                  accion: { type: string, enum: [crear_tope, crear_meta, recategorizar, ninguna] }
                  monto: { type: number }
                  model: { type: string }
        '503': { description: llm_no_configurado }

  /mia/redactar:
    post:
      tags: [MIA]
      summary: 'LlmPort · paso REDACTAR — prosa fundada SOLO en `hechos`'
      description: |
        Paso 3 del orquestador. Escribe la respuesta usando EXCLUSIVAMENTE los `hechos`
        (regla dura anti-alucinación). `conTarjeta` define el estilo (cabecera vs respuesta).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role: { type: string, enum: [user, assistant, system] }
                      content: { type: string }
                hechos: { type: string, description: 'Única fuente válida de cifras para la prosa.' }
                conTarjeta: { type: boolean }
      responses:
        '200':
          description: Texto redactado (RedactarResult)
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
                  model: { type: string }
        '503': { description: llm_no_configurado }

  /mia/bandeja:
    get:
      tags: [MIA (proactivo)]
      summary: Bandeja — movimientos que esperan acción, agrupados en tarjetas
      parameters:
        - { in: query, name: usuario, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer } }
      responses:
        '200':
          description: Bandeja del usuario
          content:
            application/json:
              schema:
                type: object
                properties:
                  resumen:
                    type: object
                    properties:
                      tarjetas: { type: integer }
                      movimientos: { type: integer }
                      sugerencias: { type: integer }
                      dudas: { type: integer }
                  tarjetas:
                    type: array
                    items:
                      type: object
                      properties:
                        clave: { type: string }
                        carril: { type: string, enum: [sugerir, preguntar] }
                        estado_mia: { type: string, enum: [sugerido, pendiente] }
                        nombre_comercio: { type: string, nullable: true }
                        categoria_sugerida_id: { type: string, nullable: true }
                        categoria_sugerida: { type: object, nullable: true }
                        movimiento_ids: { type: array, items: { type: string } }
                        cantidad: { type: integer }
                        monto_total: { type: number, nullable: true }
                        confianza_max: { type: number, nullable: true }
                        sugerir_especifica: { type: boolean }

  /mia/confirmar:
    post:
      tags: [MIA (proactivo)]
      summary: '"Sí quiero" — confirma una categoría sobre 1+ movimientos (aprende regla user-scope)'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [movimiento_ids, categoria_id]
              properties:
                movimiento_ids: { type: array, items: { type: string } }
                categoria_id: { type: string }
                usuario: { type: string }
                aprender: { type: boolean, description: 'default true: aprende la regla user-scope' }
                subcategoria_usuario_id: { type: string }
      responses:
        '200':
          description: Resultado de la confirmación
          content:
            application/json:
              schema:
                type: object
                properties:
                  confirmados: { type: integer }
                  fallidos:
                    type: array
                    items:
                      type: object
                      properties:
                        movimiento_id: { type: string }
                        error: { type: string }
                  categoria_id: { type: string }
                  categoria: { type: object, nullable: true }

  /mia/descartar:
    post:
      tags: [MIA (proactivo)]
      summary: Descarta una sugerencia o revierte una auto-categorización
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [movimiento_ids]
              properties:
                movimiento_ids: { type: array, items: { type: string } }
      responses:
        '200':
          description: Cantidad descartada
          content:
            application/json:
              schema:
                type: object
                properties:
                  descartados: { type: integer }

  /mia/auto-recientes:
    get:
      tags: [MIA (proactivo)]
      summary: Movimientos que MIA auto-categorizó (carril auto) — para el aviso "categoricé N"
      parameters:
        - { in: query, name: usuario, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer } }
        - { in: query, name: desde_horas, schema: { type: integer } }
      responses:
        '200':
          description: Auto-categorizados recientes
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        movimiento_id: { type: string }
                        nombre_comercio: { type: string, nullable: true }
                        monto: { type: string, nullable: true }
                        categoria_id: { type: string, nullable: true }
                        categoria: { type: object, nullable: true }
                        confianza: { type: string, nullable: true }
                        aplicado_at: { type: string }

  /mia/insights:
    get:
      tags: [MIA (proactivo)]
      summary: Insights por movimiento (gasto alto, recurrente, ingreso, cerca/exceso de tope)
      parameters:
        - { in: query, name: usuario, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer } }
        - { in: query, name: desde_horas, schema: { type: integer } }
        - { in: query, name: max, schema: { type: integer } }
      responses:
        '200':
          description: Insights notables
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        movimiento_id: { type: string }
                        tipo: { type: string, enum: [ingreso, gasto-alto, recurrente, cerca-tope, tope-excedido, registro] }
                        tono: { type: string, enum: [ok, warn, info] }
                        titulo: { type: string }
                        texto: { type: string }
                        hint: { type: string }
                        pregunta: { type: string }
                        comercio: { type: string }
                        monto: { type: number }
                        categoria_nombre: { type: string, nullable: true }
                        categoria_slug: { type: string, nullable: true }

  /mia/resumen:
    post:
      tags: [MIA (proactivo)]
      summary: Saludo de asesor para la home (1-2 frases redactadas de los hechos del mes)
      description: '`titulo`/`frase` null = sin LLM (el cliente usa su frase determinística).'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [balance, gastos_mes, ingresos_mes]
              properties:
                balance: { type: number }
                gastos_mes: { type: number }
                ingresos_mes: { type: number }
                delta_pct: { type: number, nullable: true }
                meta_mensual: { type: number, nullable: true }
                ahorrado: { type: number, nullable: true }
                meta_pct: { type: number, nullable: true }
                top_tope_nombre: { type: string, nullable: true }
                top_tope_pct: { type: number, nullable: true }
      responses:
        '200':
          description: Saludo redactado
          content:
            application/json:
              schema:
                type: object
                properties:
                  titulo: { type: string, nullable: true }
                  frase: { type: string, nullable: true }

  /mia/conversar:
    post:
      tags: [MIA (proactivo)]
      summary: Conversa en lenguaje natural → acción sobre la bandeja (confirmar/descartar) o aclaración
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usuario, mensaje]
              properties:
                usuario: { type: string }
                mensaje: { type: string }
                historial: { type: array, items: { type: string }, description: 'Últimos turnos (resuelve "eso"/"me equivoqué").' }
      responses:
        '200':
          description: Acción ejecutada o propuesta a confirmar
          content:
            application/json:
              schema:
                type: object
                properties:
                  accion: { type: string, enum: [confirmar, descartar, sugerir, sugerir_lote, crear_categoria, crear_presupuesto, crear_meta_ahorro, consulta, aclarar] }
                  respuesta: { type: string }
                  ejecutado: { type: object, nullable: true }
                  tarjetas: { type: array, items: { type: object } }
                  propuesta: { type: object }
                  sugerencia: { type: object }
                  lote: { type: array, items: { type: object } }
                  presupuesto: { type: object }
                  meta: { type: object }

  /stats/pipeline:
    get:
      tags: [Stats]
      summary: Distribución por capa pipeline + latencias
      parameters:
        - in: query
          name: ventana
          schema:
            type: string
            enum: [1h, 24h, 7d, 30d, all]
            default: 24h
      responses:
        '200':
          description: Stats
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /demo/config:
    get:
      tags: [Demo]
      summary: Runtime config del demo UI
      security: []
      description: |
        Endpoint público (sin auth). Devuelve `apiKey` (env `API_KEY`) +
        `hasOpenRouter` (boolean). El demo UI lo consume al bootear para
        configurar el SDK.
      responses:
        '200':
          description: Config
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiKey: { type: string }
                  hasOpenRouter: { type: boolean }

  /reglas/{id}:
    patch:
      tags: [Reglas]
      summary: Activar/desactivar o cambiar prioridad
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                activo: { type: boolean }
                prioridad: { type: integer, minimum: 1, maximum: 10000 }
      responses:
        '200': { description: Actualizada }
        '404': { description: no_existe }
    delete:
      tags: [Reglas]
      summary: Eliminar regla por id
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Eliminada }
        '404': { description: no_existe }
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key

  schemas:
    Fuente:
      type: string
      enum:
        - regex # regla tipo regex (0.95)
        - literal # regla tipo literal (0.95)
        - contiene # regla tipo contiene (0.80)
        - mcc # MCC mapeado (0.75) o MCC inferido por nombre vía mcc_por_nombre
        - ia # OpenRouter fallback (cap 0.50)
        - manual # corrección usuario o memoria personal (1.0)
        - bancard # legacy
        - nombre # legacy
        - patrones # legacy
        - prefijo # legacy
      description: |
        Fuente que asignó la categoría.
        Pipeline actual produce: `regex`, `literal`, `contiene`, `mcc`, `manual`, `ia`.
        Valores legacy (`bancard`, `nombre`, `patrones`, `prefijo`) aparecen solo en movimientos antiguos.

    CategorizarRequest:
      type: object
      description: |
        Al menos uno de: `descripcion`, `nombre_comercio`, `nombre_bancard`, `mcc`.
      properties:
        descripcion:
          type: string
          minLength: 1
          maxLength: 500
          description: Texto del movimiento (recomendado)
        nombre_comercio:
          type: string
          minLength: 1
          maxLength: 200
        nombre_bancard:
          type: string
          minLength: 1
          maxLength: 200
        mcc:
          type: string
          pattern: '^\d{2,4}$'
          description: Código MCC
        bancard_id:
          type: string
          maxLength: 100
          description: Habilita lookup directo en catálogo
        codigo_comercio:
          type: string
          maxLength: 50
        monto:
          type: number
          description: Solo persistido, no afecta categorización
        origen:
          type: string
          maxLength: 50
          example: mobile
        batch_id:
          type: string
          maxLength: 100
        bypass_catalogo:
          type: boolean
          description: Testing — salta capa 1
        categoria_id:
          type: string
          format: uuid
          description: |
            Categoría predefinida por el usuario. Si está presente, se SALTEA
            el pipeline; el movimiento se guarda con esta cat como manual
            (fuente='manual', confianza=1.0). Caso de uso: gasto manual en
            app móvil donde el usuario ya elige categoría al cargar.
        aprender:
          type: boolean
          default: false
          description: |
            Sólo aplica cuando se pasa `categoria_id` + `origen`. Si `true`,
            además del movimiento guarda regla user-scope con prio 1 para que
            próximos movs con el mismo nombre devuelvan esta categoría automático.
        subcategoria_usuario_id:
          type: string
          format: uuid
          description: |
            Opcional. Id de subcategoría personal del usuario (tabla
            `categorias_usuario`). Si se provee, backend resuelve la canónica
            padre y la usa como `categoria_id` (override silencioso si caller
            también mandó `categoria_id`). El mov queda con `categoria_id`=canon
            y `subcategoria_usuario_id`=subcat. Reports rolan al rubro canónico.

    CategorizarResponse:
      type: object
      required: [movimiento_id, categoria_id, fuente, confianza, requiere_revision]
      properties:
        movimiento_id:
          type: string
          format: uuid
        categoria_id:
          type: string
          format: uuid
          nullable: true
          description: null → cascada agotada, IA corriendo async
        fuente:
          allOf:
            - $ref: '#/components/schemas/Fuente'
          nullable: true
        confianza:
          type: number
          minimum: 0
          maximum: 1
          nullable: true
        requiere_revision:
          type: boolean
          description: true si confianza < 0.7 o categoria null

    MovimientoDetalle:
      type: object
      properties:
        id: { type: string, format: uuid }
        descripcion: { type: string, nullable: true }
        nombre_comercio: { type: string, nullable: true }
        mcc: { type: string, nullable: true }
        monto: { type: number, nullable: true }
        categoria_id: { type: string, format: uuid, nullable: true }
        categoria_slug: { type: string, nullable: true }
        categoria_confirmada_id: { type: string, format: uuid, nullable: true }
        subcategoria_usuario_id:
          type: string
          format: uuid
          nullable: true
          description: Subcategoría personal del usuario (si fue asignada).
        subcategoria:
          allOf: [{ $ref: '#/components/schemas/SubcategoriaRef' }]
          nullable: true
          description: Subcategoría poblada (nombre, emoji, color, canonica_id).
        fuente_categoria:
          allOf: [{ $ref: '#/components/schemas/Fuente' }]
          nullable: true
        confianza: { type: number, nullable: true }
        requiere_revision: { type: boolean }
        evidencia:
          type: object
          additionalProperties: true
          nullable: true
        origen: { type: string, nullable: true }
        batch_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SubcategoriaRef:
      type: object
      required: [id, nombre, slug, canonica_id]
      properties:
        id: { type: string, format: uuid }
        nombre: { type: string }
        slug: { type: string }
        emoji: { type: string, nullable: true }
        color: { type: string, nullable: true }
        canonica_id: { type: string, format: uuid }

    CategoriaUsuario:
      type: object
      required:
        - id
        - usuario_id
        - canonica_id
        - canonica_slug
        - canonica_nombre
        - nombre
        - slug
        - activo
        - origen
        - created_at
      properties:
        id: { type: string, format: uuid }
        usuario_id: { type: string, example: demo }
        canonica_id: { type: string, format: uuid }
        canonica_slug: { type: string, example: entretenimiento }
        canonica_nombre: { type: string, example: Entretenimiento }
        nombre: { type: string, example: Streaming }
        slug: { type: string, example: streaming }
        emoji: { type: string, nullable: true, example: '🎬' }
        color: { type: string, nullable: true }
        activo: { type: boolean }
        origen: { type: string, example: manual, description: 'Default `manual`; reservado para sugerencias futuras.' }
        created_at: { type: string, format: date-time }

    Regla:
      type: object
      required: [id, scope, tipo, valor, categoria_slug, prioridad, activo]
      properties:
        id: { type: string, format: uuid }
        scope: { type: string, example: 'usuario:usuario_42' }
        tipo: { type: string, enum: [literal, contiene, regex] }
        valor: { type: string }
        valor_normalizado: { type: string }
        categoria_id: { type: string, format: uuid }
        categoria_slug: { type: string }
        prioridad: { type: integer }
        descripcion: { type: string, nullable: true }
        origen: { type: string, example: manual }
        hits: { type: integer }
        activo: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Categoria:
      type: object
      required: [slug, nombre, activo]
      properties:
        slug:
          type: string
          example: transporte
        nombre:
          type: string
          example: Transporte
        descripcion:
          type: string
          nullable: true
        activo:
          type: boolean

    Presupuesto:
      type: object
      required: [id, usuario, categoria_id, categoria_slug, categoria_nombre, monto_mensual, vigente_desde, created_at]
      properties:
        id: { type: string, format: uuid }
        usuario: { type: string }
        categoria_id: { type: string, format: uuid }
        categoria_slug: { type: string }
        categoria_nombre: { type: string }
        monto_mensual: { type: number }
        vigente_desde: { type: string, format: date }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Error:
      type: object
      properties:
        error: { type: string }
        issues:
          type: object
          additionalProperties: true

  responses:
    BadRequest:
      description: Validación falló
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: API key inválida o ausente
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: unauthorized }
    NotFound:
      description: Recurso no existe
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: no_existe }
