openapi: "3.1.0"

info:
  title: EasyCash API
  description: |
    API REST para la plataforma SaaS **EasyCash** de gestión de flujo de caja proyectado y ejecutado.

    ## Autenticación
    Todos los endpoints protegidos requieren un token JWT en el header:
    ```
    Authorization: Bearer <access_token>
    ```

    El `access_token` se obtiene en `POST /auth/login` y expira en **15 minutos**.
    Use `POST /auth/refresh` (cookie `refresh_token`) para renovarlo.

    ## Formato de respuesta
    Todas las respuestas siguen el envelope:
    ```json
    {
      "data": {},
      "meta": {},
      "errors": null
    }
    ```

    ## Multiempresa
    Cada request opera dentro del contexto de la organización del usuario autenticado.
    El `organization_id` se extrae del JWT — nunca se envía en el body.
  version: "1.0.0"
  contact:
    name: EasyCash
  license:
    name: Proprietary

servers:
  - url: http://localhost:8081/api/v1
    description: Servidor de desarrollo
  - url: https://api.easycash.app/api/v1
    description: Servidor de producción

tags:
  - name: health
    description: Estado del servicio
  - name: auth
    description: Autenticación y gestión de sesión
  - name: organizations
    description: Organización del usuario autenticado
  - name: roles
    description: Roles y asignación de permisos
  - name: permissions
    description: Catálogo de permisos del sistema
  - name: users
    description: Gestión de usuarios de la organización
  - name: financial-units
    description: Unidades financieras (empresas / personas)
  - name: consolidated-groups
    description: Grupos de consolidación de unidades
  - name: bank-accounts
    description: Cuentas bancarias
  - name: categories
    description: Categorías de movimientos
  - name: exchange-rates
    description: Tipos de cambio CRC/USD
  - name: customers
    description: Clientes
  - name: suppliers
    description: Proveedores
  - name: projected-movements
    description: Movimientos proyectados, ejecución y aprobación
  - name: movement-executions
    description: Historial de ejecuciones de movimientos proyectados
  - name: dashboard
    description: Resumen financiero y flujo de caja proyectado

paths:

  # ── Projected Movements ───────────────────────────────────────────────────

  /projected-movements:
    get:
      tags: [projected-movements]
      summary: Listar movimientos proyectados
      description: |
        Devuelve todos los movimientos proyectados de la organización con paginación.
        `effective_status` se calcula al vuelo:
        si `status = pending` y `projected_date < hoy`, `effective_status = overdue`.

        El frontend debe usar `effective_status` para badges y filtros visuales.
        `status` es el valor técnico almacenado en DB.

        **No enviar** `organization_id` como query param — siempre se extrae del JWT.
      operationId: listProjectedMovements
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
          description: Número de página (1-based)
        - in: query
          name: per_page
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Resultados por página (máximo 200)
        - in: query
          name: movement_type
          schema:
            type: string
            enum: [income, expense]
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, executed, partial, cancelled]
          description: "Estado técnico en DB. Mutuamente excluyente con effective_status."
        - in: query
          name: effective_status
          schema:
            type: string
            enum: [pending, executed, partial, overdue, cancelled]
          description: "Estado semántico calculado. 'pending' excluye vencidos. Mutuamente excluyente con status."
        - in: query
          name: date_from
          schema:
            type: string
            format: date
          description: Fecha mínima de projected_date (Y-m-d)
        - in: query
          name: date_to
          schema:
            type: string
            format: date
          description: "Fecha máxima de projected_date (Y-m-d). Debe ser >= date_from."
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Devuelve 404 si no pertenece a la org
        - in: query
          name: bank_account_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Devuelve 404 si no pertenece a la org
        - in: query
          name: category_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Devuelve 404 si no pertenece a la org
        - in: query
          name: customer_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Devuelve 404 si no pertenece a la org
        - in: query
          name: supplier_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Devuelve 404 si no pertenece a la org
        - in: query
          name: source_type
          schema:
            type: string
            enum: [income_contract, expense_obligation, loan, tax_labor, manual, predefined]
          description: Filtrar por tipo de fuente generadora
        - in: query
          name: source_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: UUID de la fuente generadora (contrato, obligación, etc.)
        - in: query
          name: currency
          schema:
            type: string
            minLength: 3
            maxLength: 3
          description: "Código de moneda uppercase (ej: CRC, USD)"
      responses:
        '200':
          description: Lista paginada de movimientos proyectados
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProjectedMovementItem'
                  meta:
                    $ref: '#/components/schemas/ProjectedMovementListMeta'
                  errors:
                    nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /projected-movements/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'

    get:
      tags: [projected-movements]
      summary: Obtener movimiento proyectado
      description: |
        Devuelve el detalle completo. Incluye `version` (ausente en la lista)
        para uso en optimistic locking en fases futuras (Fase 4).
      operationId: getProjectedMovement
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Movimiento proyectado encontrado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/ProjectedMovementDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [projected-movements]
      summary: Editar movimiento proyectado
      description: |
        Permite editar manualmente un movimiento proyectado en estado `pending`.
        Un movimiento `overdue` también es editable porque su `status` real en DB es `pending`.

        **Campos requeridos:** `concept`, `projected_date`, `amount_current`.

        **Campos NO editables:** si se incluyen en el body con un valor distinto al original,
        se devuelve 422. Campos inmutables: `organization_id`, `financial_unit_id`,
        `movement_type`, `source_type`, `source_id`, `customer_id`, `supplier_id`,
        `original_date`, `amount_projected`, `currency`, `generation_sequence`,
        `parent_movement_id`, `status`, `created_by`.

        `version` se incrementa automáticamente en cada actualización.
        `updated_by` se registra con el usuario autenticado.
      operationId: updateProjectedMovement
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProjectedMovementRequest'
      responses:
        '200':
          description: Movimiento actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/ProjectedMovementDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /projected-movements/{id}/cancel:
    patch:
      tags: [projected-movements]
      summary: Cancelar movimiento proyectado
      description: |
        Cancela manualmente un movimiento proyectado en estado `pending`.
        Los movimientos `overdue` también son cancelables porque su `status` real
        en DB es `pending`.

        Requiere `expected_version` en el body para optimistic locking. Usar el valor
        de `version` devuelto por GET. Si el movimiento fue modificado concurrentemente,
        se devuelve 409.

        Únicamente cambia: `status → cancelled`, `updated_by`, `version + 1`.
        No afecta el contrato u obligación de origen.
      operationId: cancelProjectedMovement
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelProjectedMovementRequest'
      responses:
        '200':
          description: Movimiento cancelado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CancelledMovementResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /projected-movements/{id}/execute:
    post:
      tags: [projected-movements]
      summary: Ejecutar movimiento proyectado (ejecución total)
      description: |
        Registra la ejecución completa de un movimiento proyectado en estado `pending`.
        Los movimientos `overdue` también son ejecutables porque su `status` real en DB es `pending`.

        **Crea un registro en `movement_executions` y actualiza:**
        - `projected_movements.status → executed`
        - `bank_accounts.calculated_balance` (+income / -expense)

        Toda la operación es atómica. Si falla cualquier paso, se hace rollback completo.

        **Ejecución total:** `executed_amount == amount_current` → `movement_parent.status = executed`, `movement_child = null`.
        **Ejecución parcial:** `executed_amount < amount_current` → `movement_parent.status = partial`, se crea `movement_child` pending con el saldo restante.
        **No permitido:** `executed_amount > amount_current` → 422.

        La cuenta bancaria usada puede ser distinta a la sugerida en el movimiento, pero debe:
        - Pertenecer a la misma `financial_unit` del movimiento
        - Estar en estado `active`
        - Tener la misma `currency` que el movimiento
      operationId: executeProjectedMovement
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExecuteProjectedMovementRequest'
      responses:
        '201':
          description: Movimiento ejecutado, registro de ejecución creado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/ExecutionResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /projected-movements/{id}/executions:
    get:
      tags: [projected-movements]
      summary: Listar historial de ejecuciones de un movimiento proyectado
      description: |
        Devuelve el historial paginado de ejecuciones (`movement_executions`)
        registradas contra el movimiento proyectado indicado, ordenadas por
        `execution_date` y `created_at` descendente (más reciente primero).

        Valida que el movimiento proyectado pertenezca a la organización
        autenticada antes de consultar sus ejecuciones; si no existe o
        pertenece a otra organización, devuelve 404.

        No incluye reversos ni eliminación de ejecuciones (no implementados).
      operationId: listMovementExecutionsByProjectedMovement
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
          description: Número de página (1-based)
        - in: query
          name: per_page
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Resultados por página (máximo 200)
      responses:
        '200':
          description: Lista paginada de ejecuciones del movimiento proyectado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MovementExecutionItem'
                  meta:
                    $ref: '#/components/schemas/MovementExecutionListMeta'
                  errors:
                    nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Reschedules ──────────────────────────────────────────────────────────

  /projected-movements/{id}/reschedule:
    post:
      tags: [projected-movements]
      summary: Reprogramar un movimiento proyectado
      description: |
        Actualiza `projected_date` del movimiento in-place (sin crear movimientos
        hijos). `original_date` nunca se modifica.

        Solo movimientos en estado `pending` u `overdue` pueden reprogramarse.
        Requiere `expected_version` para optimistic locking. Si la versión no
        coincide con la actual, devuelve 409.

        La operación es transaccional:
        1. Actualiza `projected_movements.projected_date` con lock de versión.
        2. Inserta un registro en `reschedules` con el delta de fechas, motivo y usuario.
        3. Inserta un registro en `audit_log` con `action = 'reschedule'`.
      operationId: createReschedule
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
          description: UUID del movimiento proyectado
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_date, reason, expected_version]
              properties:
                new_date:
                  type: string
                  format: date
                  description: Nueva fecha proyectada. Debe ser >= hoy y diferente a la fecha actual del movimiento.
                  example: '2026-07-15'
                reason:
                  type: string
                  enum: [atraso_cliente, acuerdo_pago, falta_factura, atraso_pago, error_fecha, otro]
                  description: Motivo de la reprogramación
                notes:
                  type: string
                  nullable: true
                  description: Comentario libre adicional (opcional)
                expected_version:
                  type: integer
                  minimum: 1
                  description: Versión actual del movimiento (optimistic locking)
      responses:
        '201':
          description: Movimiento reprogramado exitosamente
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reschedule:
                        $ref: '#/components/schemas/RescheduleItem'
                      movement:
                        type: object
                        properties:
                          id:             { type: string, format: uuid }
                          projected_date: { type: string, format: date }
                          original_date:  { type: string, format: date }
                          version:        { type: integer }
                          updated_by:     { type: string, format: uuid }
                          updated_at:     { type: string, format: date-time }
                  errors:
                    nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /projected-movements/{id}/reschedules:
    get:
      tags: [projected-movements]
      summary: Listar historial de reprogramaciones de un movimiento
      description: |
        Devuelve todas las reprogramaciones registradas en `reschedules` para
        el movimiento indicado, ordenadas por `created_at` descendente.

        Reutiliza el permiso `projected_movements.read`, ya que el historial
        forma parte del detalle del movimiento.
      operationId: listReschedules
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
          description: UUID del movimiento proyectado
      responses:
        '200':
          description: Lista de reprogramaciones (más reciente primero)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/RescheduleItem'
                  errors:
                    nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Movement Executions ───────────────────────────────────────────────────

  /movement-executions/{id}:
    get:
      tags: [movement-executions]
      summary: Obtener detalle de una ejecución
      description: |
        Devuelve el detalle completo de una ejecución de movimiento
        (`movement_executions`), incluyendo `organization_id` y
        `financial_unit_id`. Filtrado siempre por la organización autenticada;
        una ejecución de otra organización devuelve 404.
      operationId: getMovementExecution
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      responses:
        '200':
          description: Ejecución encontrada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/MovementExecutionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Expense Obligations ───────────────────────────────────────────────────

  /expense-obligations:
    get:
      tags: [expense-obligations]
      summary: Listar obligaciones de gasto
      operationId: listExpenseObligations
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [active, inactive, completed]
          description: Filtrar por estado (default solo active)
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera
        - in: query
          name: supplier_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por proveedor
        - in: query
          name: frequency
          schema:
            type: string
            enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
        - in: query
          name: amount_type
          schema:
            type: string
            enum: [fixed, variable]
          description: Filtrar por tipo de monto
        - in: query
          name: include_inactive
          schema:
            type: string
            enum: ['true']
      responses:
        '200':
          description: Lista de obligaciones de gasto
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/ExpenseObligationItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    post:
      tags: [expense-obligations]
      summary: Crear obligación de gasto
      description: |
        Crea la obligación y genera automáticamente los `projected_movements` (type=expense)
        dentro de la ventana de proyección. Para `once`, genera 1 movimiento inmediatamente.
      operationId: createExpenseObligation
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateExpenseObligationRequest'
      responses:
        '201':
          description: Obligación creada y movimientos generados
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/ExpenseObligationItem'
                          - type: object
                            properties:
                              movements_created:
                                type: integer
                                example: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /expense-obligations/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'

    get:
      tags: [expense-obligations]
      summary: Obtener obligación de gasto
      operationId: getExpenseObligation
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Obligación encontrada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/ExpenseObligationDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [expense-obligations]
      summary: Actualizar obligación de gasto
      description: |
        `financial_unit_id` y `start_date` son inmutables: si se incluyen con un valor
        distinto al original, se devuelve 422. `supplier_id` sí es editable.

        Si cambian `base_amount`, `frequency`, `expected_day` o `end_date`, los
        `projected_movements` pending se cancelan y se regeneran en la ventana actual.
      operationId: updateExpenseObligation
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateExpenseObligationRequest'
      responses:
        '200':
          description: Obligación actualizada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/ExpenseObligationItem'
                          - type: object
                            properties:
                              regeneration:
                                $ref: '#/components/schemas/RegenerationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /expense-obligations/{id}/status:
    patch:
      tags: [expense-obligations]
      summary: Cambiar estado de la obligación
      description: |
        Transiciones permitidas:
        - `active` → `inactive` (pausa)
        - `active` → `completed` (cierre manual)
        - `inactive` → `active` (reactivación)
        - `completed` es terminal — no se puede cambiar.
      operationId: changeExpenseObligationStatus
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangeObligationStatusRequest'
      responses:
        '200':
          description: Estado actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id:
                            $ref: '#/components/schemas/Uuid'
                          obligation_name:
                            type: string
                          status:
                            type: string
                            enum: [active, inactive, completed]
                          updated_at:
                            $ref: '#/components/schemas/Timestamp'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Income Contracts ──────────────────────────────────────────────────────

  /income-contracts:
    get:
      tags: [income-contracts]
      summary: Listar contratos de ingreso
      operationId: listIncomeContracts
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [active, inactive, completed]
          description: Filtrar por estado (default solo active)
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera
        - in: query
          name: customer_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por cliente
        - in: query
          name: frequency
          schema:
            type: string
            enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
          description: Filtrar por frecuencia
        - in: query
          name: include_inactive
          schema:
            type: string
            enum: ['true']
          description: Incluir contratos inactivos y completados
      responses:
        '200':
          description: Lista de contratos de ingreso
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/IncomeContractItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    post:
      tags: [income-contracts]
      summary: Crear contrato de ingreso
      description: |
        Crea el contrato y genera automáticamente los `projected_movements`
        dentro de la ventana de proyección configurada (`PROJECTION_WINDOW_DAYS`).
        Para contratos `once`, genera 1 movimiento inmediatamente.
      operationId: createIncomeContract
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateIncomeContractRequest'
      responses:
        '201':
          description: Contrato creado y movimientos generados
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/IncomeContractItem'
                          - type: object
                            properties:
                              movements_created:
                                type: integer
                                description: Número de projected_movements generados
                                example: 3
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /income-contracts/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'
        description: UUID del contrato

    get:
      tags: [income-contracts]
      summary: Obtener contrato de ingreso
      description: Devuelve el detalle del contrato más un resumen de sus movimientos proyectados.
      operationId: getIncomeContract
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Contrato encontrado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/IncomeContractDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [income-contracts]
      summary: Actualizar contrato de ingreso
      description: |
        Los campos `financial_unit_id`, `customer_id` y `start_date` son inmutables:
        si se incluyen en el body con un valor distinto al original, se devuelve 422.

        Si cambian `base_amount`, `frequency`, `expected_day` o `end_date`, los
        `projected_movements` en estado `pending` de este contrato se cancelan y se
        regeneran automáticamente dentro de la ventana de proyección.
      operationId: updateIncomeContract
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateIncomeContractRequest'
      responses:
        '200':
          description: Contrato actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/IncomeContractItem'
                          - type: object
                            properties:
                              regeneration:
                                $ref: '#/components/schemas/RegenerationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /income-contracts/{id}/status:
    patch:
      tags: [income-contracts]
      summary: Cambiar estado del contrato
      description: |
        Transiciones permitidas:
        - `active` → `inactive` (pausa; los pending no se cancelan automáticamente)
        - `active` → `completed` (cierre manual)
        - `inactive` → `active` (reactivación)
        - `completed` es un estado terminal — no se puede cambiar.
      operationId: changeIncomeContractStatus
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangeContractStatusRequest'
      responses:
        '200':
          description: Estado actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id:
                            $ref: '#/components/schemas/Uuid'
                          contract_name:
                            type: string
                          status:
                            type: string
                            enum: [active, inactive, completed]
                          updated_at:
                            $ref: '#/components/schemas/Timestamp'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Health ────────────────────────────────────────────────────────────────

  /health:
    servers:
      - url: http://localhost:8081
        description: Servidor de desarrollo (sin prefijo /api/v1)
      - url: https://api.easycash.app
        description: Servidor de producción (sin prefijo /api/v1)
    get:
      tags: [health]
      summary: Estado del servicio
      description: Verifica que la API y la base de datos estén operativos.
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Servicio operativo
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/HealthData'

  # ── Auth ─────────────────────────────────────────────────────────────────

  /auth/login:
    post:
      tags: [auth]
      summary: Iniciar sesión
      description: |
        Autentica al usuario con email y contraseña.
        Devuelve un `access_token` JWT (15 min) y establece una cookie
        `HttpOnly` con el `refresh_token` (7 días).
      operationId: postAuthLogin
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login exitoso
          headers:
            Set-Cookie:
              description: refresh_token HttpOnly cookie
              schema:
                type: string
                example: "refresh_token=<token>; Path=/api/v1/auth; HttpOnly; SameSite=Strict; Max-Age=604800"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/LoginData'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /auth/refresh:
    post:
      tags: [auth]
      summary: Renovar access token
      description: |
        Genera un nuevo `access_token` usando el `refresh_token` de la cookie
        `HttpOnly`. Rota el `refresh_token` (el anterior queda invalidado).
      operationId: postAuthRefresh
      security: []
      parameters:
        - in: cookie
          name: refresh_token
          required: true
          schema:
            type: string
          description: Refresh token emitido en login o refresh previo
      responses:
        '200':
          description: Token renovado correctamente
          headers:
            Set-Cookie:
              description: Nuevo refresh_token HttpOnly cookie
              schema:
                type: string
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/RefreshData'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/logout:
    post:
      tags: [auth]
      summary: Cerrar sesión
      description: |
        Invalida el `refresh_token` de la cookie y lo borra del cliente.
        Requiere JWT válido en el header `Authorization`.
      operationId: postAuthLogout
      security:
        - bearerAuth: []
      parameters:
        - in: cookie
          name: refresh_token
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Sesión cerrada correctamente
          headers:
            Set-Cookie:
              description: Cookie refresh_token con Max-Age=0 (eliminada)
              schema:
                type: string
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Sesión cerrada correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/me:
    get:
      tags: [auth]
      summary: Perfil del usuario autenticado
      description: Devuelve los datos del usuario activo junto con sus permisos.
      operationId: getAuthMe
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Perfil obtenido correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /auth/me/profile:
    put:
      tags: [auth]
      summary: Actualizar perfil
      description: Actualiza el nombre y/o teléfono del usuario autenticado.
      operationId: putAuthMeProfile
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProfileRequest'
      responses:
        '200':
          description: Perfil actualizado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserBasic'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /auth/me/password:
    patch:
      tags: [auth]
      summary: Cambiar contraseña
      description: |
        Cambia la contraseña del usuario autenticado.
        Requiere la contraseña actual para confirmar identidad.
        La nueva contraseña debe tener al menos 8 caracteres.
      operationId: patchAuthMePassword
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '200':
          description: Contraseña actualizada correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Contraseña actualizada correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Categories ────────────────────────────────────────────────────────────

  /categories:
    get:
      tags: [categories]
      summary: Listar categorías
      operationId: listCategories
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: type
          schema:
            type: string
            enum: [income, expense, both]
          description: Filtrar por tipo
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera (debe pertenecer a la org)
        - in: query
          name: include_inactive
          schema:
            type: string
            enum: ['true']
          description: Incluir registros inactivos (por defecto solo activos)
      responses:
        '200':
          description: Lista de categorías
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/CategoryItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    post:
      tags: [categories]
      summary: Crear categoría
      operationId: createCategory
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCategoryRequest'
      responses:
        '201':
          description: Categoría creada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CategoryItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /categories/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'

    get:
      tags: [categories]
      summary: Obtener categoría
      operationId: getCategory
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Categoría encontrada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CategoryItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [categories]
      summary: Actualizar categoría
      operationId: updateCategory
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCategoryRequest'
      responses:
        '200':
          description: Categoría actualizada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CategoryItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [categories]
      summary: Desactivar categoría (soft delete)
      description: |
        Cambia status a `inactive`. Devuelve 409 si la categoría está en uso
        en movimientos o contratos activos.
      operationId: deleteCategory
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Categoría desactivada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Categoría desactivada correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  # ── Customers ─────────────────────────────────────────────────────────────

  /customers:
    get:
      tags: [customers]
      summary: Listar clientes
      operationId: listCustomers
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera
        - in: query
          name: include_inactive
          schema:
            type: string
            enum: ['true']
          description: Incluir inactivos (por defecto solo activos)
      responses:
        '200':
          description: Lista de clientes
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/CustomerItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [customers]
      summary: Crear cliente
      operationId: createCustomer
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCustomerRequest'
      responses:
        '201':
          description: Cliente creado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CustomerItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /customers/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'

    get:
      tags: [customers]
      summary: Obtener cliente
      operationId: getCustomer
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Cliente encontrado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CustomerItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [customers]
      summary: Actualizar cliente
      operationId: updateCustomer
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCustomerRequest'
      responses:
        '200':
          description: Cliente actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CustomerItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [customers]
      summary: Desactivar cliente (soft delete)
      operationId: deleteCustomer
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Cliente desactivado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Cliente desactivado correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Suppliers ─────────────────────────────────────────────────────────────

  /suppliers:
    get:
      tags: [suppliers]
      summary: Listar proveedores
      operationId: listSuppliers
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: financial_unit_id
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera
        - in: query
          name: include_inactive
          schema:
            type: string
            enum: ['true']
          description: Incluir inactivos (por defecto solo activos)
      responses:
        '200':
          description: Lista de proveedores
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/SupplierItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [suppliers]
      summary: Crear proveedor
      operationId: createSupplier
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSupplierRequest'
      responses:
        '201':
          description: Proveedor creado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/SupplierItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /suppliers/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'

    get:
      tags: [suppliers]
      summary: Obtener proveedor
      operationId: getSupplier
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Proveedor encontrado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/SupplierItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [suppliers]
      summary: Actualizar proveedor
      operationId: updateSupplier
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSupplierRequest'
      responses:
        '200':
          description: Proveedor actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/SupplierItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [suppliers]
      summary: Desactivar proveedor (soft delete)
      operationId: deleteSupplier
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Proveedor desactivado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Proveedor desactivado correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /suppliers/{id}/status:
    patch:
      tags: [suppliers]
      summary: Cambiar estado del proveedor
      description: |
        Transiciones permitidas:
        - `active` → `inactive` (desactivar)
        - `inactive` → `active` (reactivar)
      operationId: changeSupplierStatus
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [active, inactive]
                  example: "active"
      responses:
        '200':
          description: Estado actualizado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id:
                            $ref: '#/components/schemas/Uuid'
                          name:
                            type: string
                            example: "Distribuidora ABC"
                          status:
                            type: string
                            enum: [active, inactive]
                            example: "active"
                          updated_at:
                            $ref: '#/components/schemas/Timestamp'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Financial Units ───────────────────────────────────────────────────────

  /financial-units:
    get:
      tags: [financial-units]
      summary: Listar unidades financieras
      operationId: listFinancialUnits
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lista de unidades financieras
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/FinancialUnitItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [financial-units]
      summary: Crear unidad financiera
      operationId: createFinancialUnit
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFinancialUnitRequest'
      responses:
        '201':
          description: Unidad financiera creada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/FinancialUnitItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /financial-units/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'
        description: UUID de la unidad financiera

    get:
      tags: [financial-units]
      summary: Obtener unidad financiera
      operationId: getFinancialUnit
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Unidad financiera encontrada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/FinancialUnitItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [financial-units]
      summary: Actualizar unidad financiera
      operationId: updateFinancialUnit
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateFinancialUnitRequest'
      responses:
        '200':
          description: Unidad actualizada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/FinancialUnitItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [financial-units]
      summary: Desactivar unidad financiera (soft delete)
      description: |
        Cambia el status a `inactive`. No elimina físicamente el registro.
        Devuelve 409 si la unidad tiene cuentas bancarias activas asociadas.
      operationId: deleteFinancialUnit
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Unidad desactivada correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Unidad financiera desactivada correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  # ── Bank Accounts ─────────────────────────────────────────────────────────

  /bank-accounts:
    get:
      tags: [bank-accounts]
      summary: Listar cuentas bancarias
      operationId: listBankAccounts
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: financial_unit_id
          required: false
          schema:
            $ref: '#/components/schemas/Uuid'
          description: Filtrar por unidad financiera (debe pertenecer a la org)
      responses:
        '200':
          description: Lista de cuentas bancarias
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/BankAccountItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    post:
      tags: [bank-accounts]
      summary: Crear cuenta bancaria
      operationId: createBankAccount
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBankAccountRequest'
      responses:
        '201':
          description: Cuenta bancaria creada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BankAccountItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /bank-accounts/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'
        description: UUID de la cuenta bancaria

    get:
      tags: [bank-accounts]
      summary: Obtener cuenta bancaria
      operationId: getBankAccount
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Cuenta bancaria encontrada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BankAccountItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [bank-accounts]
      summary: Actualizar cuenta bancaria
      operationId: updateBankAccount
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateBankAccountRequest'
      responses:
        '200':
          description: Cuenta actualizada
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BankAccountItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [bank-accounts]
      summary: Desactivar cuenta bancaria (soft delete)
      description: Cambia el status a `inactive`. No elimina físicamente el registro.
      operationId: deleteBankAccount
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Cuenta desactivada correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Cuenta bancaria desactivada correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /bank-accounts/{id}/balance:
    patch:
      tags: [bank-accounts]
      summary: Ajustar saldo real de cuenta bancaria
      description: |
        Actualiza `real_balance` y registra `last_balance_update`.
        El campo `balance_difference` se recalcula automáticamente en la base de datos.
        El saldo debe ser >= 0. Los montos se devuelven como string para evitar
        problemas de punto flotante en el frontend.
      operationId: adjustBankAccountBalance
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdjustBalanceRequest'
      responses:
        '200':
          description: Saldo ajustado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BankAccountItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Users ─────────────────────────────────────────────────────────────────

  /users:
    get:
      tags: [users]
      summary: Listar usuarios de la organización
      operationId: listUsers
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lista de usuarios
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/UserItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [users]
      summary: Crear usuario
      operationId: createUser
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: Usuario creado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /users/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          $ref: '#/components/schemas/Uuid'
        description: UUID del usuario

    get:
      tags: [users]
      summary: Obtener usuario por ID
      operationId: getUser
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Usuario encontrado
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [users]
      summary: Actualizar usuario
      operationId: updateUser
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserRequest'
      responses:
        '200':
          description: Usuario actualizado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

    delete:
      tags: [users]
      summary: Eliminar usuario
      description: No se puede eliminar la propia cuenta del usuario autenticado.
      operationId: deleteUser
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Usuario eliminado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message:
                            type: string
                            example: "Usuario eliminado correctamente"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /users/{id}/status:
    patch:
      tags: [users]
      summary: Cambiar estado de un usuario
      description: |
        Activa o desactiva un usuario. No se puede cambiar el estado
        del propio usuario autenticado.
      operationId: changeUserStatus
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
          description: UUID del usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangeUserStatusRequest'
      responses:
        '200':
          description: Estado actualizado correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/UserItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  # ── Organizations ────────────────────────────────────────────────────────

  /organizations/current:
    get:
      tags: [organizations]
      summary: Organización del usuario autenticado
      description: Devuelve los datos de la organización extraída del JWT del usuario.
      operationId: getCurrentOrganization
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Organización obtenida correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/OrganizationData'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Permissions ───────────────────────────────────────────────────────────

  /permissions:
    get:
      tags: [permissions]
      summary: Catálogo de permisos del sistema
      description: |
        Devuelve los 72 permisos disponibles en el sistema, ordenados por módulo y acción.
        Útil para construir interfaces de asignación de permisos a roles.
      operationId: listPermissions
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lista de permisos
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/PermissionData'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ── Roles ─────────────────────────────────────────────────────────────────

  /roles:
    get:
      tags: [roles]
      summary: Roles de la organización
      description: |
        Devuelve los roles del sistema (is_system=true) más los roles
        personalizados de la organización del usuario autenticado.
      operationId: listRoles
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lista de roles
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/RoleData'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /roles/{id}:
    get:
      tags: [roles]
      summary: Detalle de un rol
      description: Devuelve los datos de un rol junto con los slugs de sus permisos asignados.
      operationId: getRole
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            $ref: '#/components/schemas/Uuid'
          description: UUID del rol
      responses:
        '200':
          description: Rol obtenido correctamente
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/RoleDetailData'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ── Dashboard ─────────────────────────────────────────────────────────────

  /dashboard:
    get:
      tags: [dashboard]
      summary: Resumen financiero por rango de fechas
      description: |
        Devuelve el resumen financiero del período indicado: saldo inicial real,
        cobros esperados, pagos programados, flujo neto y saldo final esperado.

        **Breakdown por moneda**: cuando `currency` no se envía, la respuesta
        incluye un mapa `currencies` con una entrada por moneda detectada en
        cuentas bancarias y movimientos. No se mezclan denominaciones distintas.

        **Cuando `currency` sí se envía**: la respuesta es plana (un solo bloque)
        para esa moneda específica.

        **effective_status**: calculado al vuelo — si `status = pending` y
        `projected_date < hoy`, el movimiento cuenta como `overdue`.
        Los movimientos con `status = cancelled` se excluyen del cálculo.

        **No enviar** `organization_id` — siempre se extrae del JWT.
      operationId: getDashboard
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: date_from
          required: true
          schema:
            type: string
            format: date
            example: "2026-06-01"
          description: Inicio del rango (YYYY-MM-DD)
        - in: query
          name: date_to
          required: true
          schema:
            type: string
            format: date
            example: "2026-06-30"
          description: Fin del rango (YYYY-MM-DD)
        - in: query
          name: financial_unit_id
          schema:
            type: string
            format: uuid
          description: Filtro por unidad financiera (opcional)
        - in: query
          name: bank_account_id
          schema:
            type: string
            format: uuid
          description: Filtro por cuenta bancaria (opcional)
        - in: query
          name: currency
          schema:
            type: string
            minLength: 3
            maxLength: 3
            example: "CRC"
          description: |
            Código de moneda ISO 4217 (opcional).
            Si se omite, la respuesta incluye un breakdown por todas las monedas.
      responses:
        '200':
          description: Resumen financiero
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessResponse'
                  - type: object
                    properties:
                      data:
                        oneOf:
                          - $ref: '#/components/schemas/DashboardSingleCurrency'
                          - $ref: '#/components/schemas/DashboardMultiCurrency'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: "JWT obtenido en POST /auth/login"

  schemas:
    # ── Envelope genérico ────────────────────────────────────────────
    SuccessResponse:
      type: object
      properties:
        data:
          description: Payload de la respuesta
        meta:
          $ref: '#/components/schemas/Meta'
        errors:
          type: "null"

    ErrorResponse:
      type: object
      properties:
        data:
          type: "null"
        meta:
          type: "null"
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ErrorItem'

    ValidationErrorResponse:
      type: object
      properties:
        data:
          type: "null"
        meta:
          type: "null"
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ValidationError'

    Meta:
      type: object
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total:
          type: integer
          example: 150
        last_page:
          type: integer
          example: 6

    ErrorItem:
      type: object
      properties:
        code:
          type: string
          example: "UNAUTHORIZED"
        message:
          type: string
          example: "Token inválido o expirado"

    ValidationError:
      type: object
      properties:
        field:
          type: string
          example: "email"
        message:
          type: string
          example: "El campo email es requerido"

    # ── Projected Movements ───────────────────────────────────────────
    ProjectedMovementItem:
      type: object
      description: |
        Movimiento proyectado. `effective_status` se calcula al vuelo —
        el frontend debe usarlo para badges y filtros visuales.
        `status` es el valor técnico en DB.
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        organization_id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        movement_type:
          type: string
          enum: [income, expense]
          example: "income"
        source_type:
          type: string
          enum: [income_contract, expense_obligation, loan, tax_labor, manual, predefined]
          example: "income_contract"
        source_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: UUID de la entidad generadora
        customer_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        supplier_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        concept:
          type: string
          example: "Servicio mensual cliente X"
        description:
          type: string
          nullable: true
        original_date:
          type: string
          format: date
          description: Fecha original inmutable del cálculo de frecuencia
          example: "2026-07-05"
        projected_date:
          type: string
          format: date
          description: Fecha actual (puede cambiar por reprogramación)
          example: "2026-07-05"
        amount_projected:
          type: string
          description: Monto original inmutable (string para evitar floating point)
          example: "150000.00"
        amount_current:
          type: string
          description: Monto ajustable para el período
          example: "150000.00"
        currency:
          type: string
          example: "CRC"
        invoice_number:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, executed, partial, overdue, cancelled]
          description: Valor técnico almacenado en DB
          example: "pending"
        effective_status:
          type: string
          enum: [pending, executed, partial, overdue, cancelled]
          description: |
            Estado semántico calculado al vuelo.
            Si status=pending y projected_date < hoy → overdue.
            Usar este campo para mostrar badges y filtros visuales en el frontend.
          example: "overdue"
        generation_sequence:
          type: integer
          nullable: true
          description: Número de ocurrencia N desde el contrato/obligación
          example: 3
        parent_movement_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: UUID del movimiento padre (en ejecuciones parciales)
        notes:
          type: string
          nullable: true
        created_by:
          $ref: '#/components/schemas/Uuid'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    ProjectedMovementDetail:
      allOf:
        - $ref: '#/components/schemas/ProjectedMovementItem'
        - type: object
          properties:
            version:
              type: integer
              description: Versión para optimistic locking (Fase 4)
              example: 1
            updated_by:
              allOf:
                - $ref: '#/components/schemas/Uuid'
              nullable: true

    ProjectedMovementListMeta:
      type: object
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 50
        total:
          type: integer
          description: Total de registros que coinciden con los filtros
          example: 143
        total_pages:
          type: integer
          example: 3

    ExecuteProjectedMovementRequest:
      type: object
      required: [bank_account_id, execution_date, executed_amount, expected_version]
      description: |
        Si `executed_amount == amount_current` → ejecución total (`movement_child: null`).
        Si `executed_amount < amount_current` → ejecución parcial (crea movimiento hijo).
        Si `executed_amount > amount_current` → 422.

        `expected_version` es requerido para optimistic locking. Usar el valor de `version`
        devuelto por GET. Si el movimiento fue modificado concurrentemente, se devuelve 409.
      properties:
        expected_version:
          type: integer
          minimum: 1
          description: Versión actual del movimiento (obtenida del GET). Previene doble ejecución concurrente.
          example: 1
        bank_account_id:
          $ref: '#/components/schemas/Uuid'
          description: |
            Cuenta bancaria donde se recibe/paga. Puede diferir de la sugerida en el movimiento.
            Debe pertenecer a la misma financial_unit, estar active y tener la misma currency.
        execution_date:
          type: string
          format: date
          description: Fecha real de cobro/pago. Puede ser pasada (tardía) o futura (anticipada).
          example: "2026-06-05"
        executed_amount:
          type: number
          exclusiveMinimum: 0
          description: |
            Monto ejecutado. Si es menor que `amount_current`, se crea un movimiento hijo
            con el saldo pendiente (`movement_child` en la respuesta).
          example: 150000
        bank_transaction_number:
          type: string
          nullable: true
          maxLength: 100
          description: Número de referencia de la transacción bancaria
          example: "TRN-2026-001"
        notes:
          type: string
          nullable: true
        child_projected_date:
          type: string
          format: date
          nullable: true
          description: |
            Solo aplicable en ejecución parcial. Fecha proyectada del movimiento hijo.
            Si no se envía, el hijo hereda `projected_date` del movimiento padre.
          example: "2026-07-05"

    ExecutionResult:
      type: object
      description: |
        Shape consistente para ejecución total y parcial.
        En ejecución total: `movement_child` es null.
        En ejecución parcial: `movement_child` contiene el movimiento hijo pendiente.
      properties:
        execution:
          $ref: '#/components/schemas/MovementExecutionItem'
        movement_parent:
          type: object
          description: El movimiento original (executed en total, partial en parcial)
          properties:
            id:
              $ref: '#/components/schemas/Uuid'
            status:
              type: string
              enum: [executed, partial]
            effective_status:
              type: string
              enum: [executed, partial]
            amount_current:
              type: string
              description: Monto actual del padre (inmutable tras la ejecución)
            version:
              type: integer
            updated_by:
              $ref: '#/components/schemas/Uuid'
            updated_at:
              $ref: '#/components/schemas/Timestamp'
        movement_child:
          nullable: true
          description: |
            Presente solo en ejecución parcial. Nuevo movimiento pending por el saldo restante.
            Null en ejecución total.
          type: object
          properties:
            id:
              $ref: '#/components/schemas/Uuid'
            status:
              type: string
              enum: [pending]
            effective_status:
              type: string
              enum: [pending, overdue]
            amount_projected:
              type: string
              description: Saldo restante (inmutable)
            amount_current:
              type: string
              description: Saldo restante (ajustable con PUT)
            projected_date:
              type: string
              format: date
            parent_movement_id:
              $ref: '#/components/schemas/Uuid'
            generation_sequence:
              nullable: true
              type: integer
              description: null — el hijo no es generado por el motor de frecuencias
            version:
              type: integer
              example: 1
        bank_account:
          type: object
          properties:
            id:
              $ref: '#/components/schemas/Uuid'
            calculated_balance:
              type: string
              description: Saldo calculado actualizado (string para evitar floating point)
              example: "1150000.00"
            last_balance_update:
              $ref: '#/components/schemas/Timestamp'

    RescheduleItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        projected_movement_id:
          $ref: '#/components/schemas/Uuid'
        previous_date:
          type: string
          format: date
          example: '2026-06-01'
        new_date:
          type: string
          format: date
          example: '2026-07-15'
        reason:
          type: string
          enum: [atraso_cliente, acuerdo_pago, falta_factura, atraso_pago, error_fecha, otro]
        notes:
          type: string
          nullable: true
        changed_by:
          $ref: '#/components/schemas/Uuid'
        changed_by_name:
          type: string
          example: 'Carlos García'
        created_at:
          $ref: '#/components/schemas/Timestamp'

    MovementExecutionItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        projected_movement_id:
          $ref: '#/components/schemas/Uuid'
        bank_account_id:
          $ref: '#/components/schemas/Uuid'
        execution_date:
          type: string
          format: date
          example: "2026-06-05"
        executed_amount:
          type: string
          description: Monto ejecutado como string
          example: "150000.00"
        currency:
          type: string
          example: "CRC"
        bank_transaction_number:
          type: string
          nullable: true
          example: "TRN-2026-001"
        notes:
          type: string
          nullable: true
        created_by:
          $ref: '#/components/schemas/Uuid'
        created_at:
          $ref: '#/components/schemas/Timestamp'

    MovementExecutionDetail:
      description: |
        Detalle completo de una ejecución. Extiende `MovementExecutionItem`
        agregando `organization_id` y `financial_unit_id`.
      allOf:
        - $ref: '#/components/schemas/MovementExecutionItem'
        - type: object
          properties:
            organization_id:
              $ref: '#/components/schemas/Uuid'
            financial_unit_id:
              $ref: '#/components/schemas/Uuid'

    MovementExecutionListMeta:
      type: object
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 50
        total:
          type: integer
          description: Total de ejecuciones del movimiento proyectado
          example: 2
        total_pages:
          type: integer
          example: 1

    CancelledMovementResult:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        status:
          type: string
          enum: [cancelled]
          example: "cancelled"
        effective_status:
          type: string
          enum: [cancelled]
          example: "cancelled"
        version:
          type: integer
          description: Versión incrementada tras la cancelación
          example: 8
        updated_by:
          $ref: '#/components/schemas/Uuid'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CancelProjectedMovementRequest:
      type: object
      required: [expected_version]
      description: |
        Body requerido para cancelar un movimiento proyectado.
        `expected_version` es necesario para optimistic locking.
      properties:
        expected_version:
          type: integer
          minimum: 1
          description: Versión actual del movimiento (obtenida del GET). Previene cancelaciones concurrentes.
          example: 1

    UpdateProjectedMovementRequest:
      type: object
      required: [concept, projected_date, amount_current, expected_version]
      description: |
        Campos editables de un movimiento proyectado en estado `pending`.
        Los campos inmutables (movement_type, source_id, amount_projected, currency, etc.)
        son rechazados con 422 si se envían con valor distinto al original.

        `expected_version` es requerido para optimistic locking. Usar el valor de `version`
        devuelto por GET. Si el movimiento fue modificado concurrentemente, se devuelve 409.
      properties:
        expected_version:
          type: integer
          minimum: 1
          description: Versión actual del movimiento (obtenida del GET). Previene sobrescrituras concurrentes.
          example: 1
        concept:
          type: string
          minLength: 1
          maxLength: 300
          example: "Concepto actualizado"
        description:
          type: string
          nullable: true
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: |
            Si movement_type=income, debe ser tipo income o both.
            Si movement_type=expense, debe ser tipo expense o both.
        projected_date:
          type: string
          format: date
          description: Fecha de cobro/pago proyectada. Puede ser en el pasado (reprogramación de vencidos).
          example: "2026-07-10"
        amount_current:
          type: number
          exclusiveMinimum: 0
          description: Monto ajustado para este período. No modifica amount_projected (monto base inmutable).
          example: 145000
        invoice_number:
          type: string
          nullable: true
          example: "F-2026-001"
        notes:
          type: string
          nullable: true
          example: "Ajustado por acuerdo con el cliente"
        bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Debe pertenecer a la misma financial_unit del movimiento.

    # ── Expense Obligations ───────────────────────────────────────────
    ExpenseObligationItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        supplier_id:
          $ref: '#/components/schemas/Uuid'
          description: Proveedor asociado (requerido)
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Debe ser de tipo expense o both
        obligation_name:
          type: string
          example: "Alquiler oficina"
        description:
          type: string
          nullable: true
        base_amount:
          type: string
          description: Monto base como string (evita floating point)
          example: "350000.00"
        currency:
          type: string
          example: "CRC"
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
          example: "monthly"
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
          description: Null usa el día de start_date
          example: 1
        start_date:
          type: string
          format: date
          example: "2026-01-01"
        end_date:
          type: string
          format: date
          nullable: true
        payment_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Cuenta de pago (debe pertenecer a financial_unit_id)
        amount_type:
          type: string
          enum: [fixed, variable]
          description: fixed=monto definitivo, variable=estimación ajustable en el movimiento
          example: "fixed"
        status:
          type: string
          enum: [active, inactive, completed]
          example: "active"
        source:
          type: string
          enum: [manual, excel, easytax]
          example: "manual"
        created_by:
          $ref: '#/components/schemas/Uuid'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    ExpenseObligationDetail:
      allOf:
        - $ref: '#/components/schemas/ExpenseObligationItem'
        - type: object
          properties:
            movements_summary:
              $ref: '#/components/schemas/MovementsSummary'

    CreateExpenseObligationRequest:
      type: object
      required: [financial_unit_id, supplier_id, obligation_name, base_amount, frequency, amount_type, start_date]
      properties:
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        supplier_id:
          $ref: '#/components/schemas/Uuid'
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Debe ser de tipo expense o both
        obligation_name:
          type: string
          maxLength: 300
          example: "Alquiler oficina"
        description:
          type: string
          nullable: true
        base_amount:
          type: number
          exclusiveMinimum: 0
          example: 350000
        currency:
          type: string
          default: "CRC"
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
          description: Null usa el día de start_date
        start_date:
          type: string
          format: date
          example: "2026-01-01"
        end_date:
          type: string
          format: date
          nullable: true
        payment_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        amount_type:
          type: string
          enum: [fixed, variable]
          example: "fixed"

    UpdateExpenseObligationRequest:
      type: object
      required: [obligation_name, base_amount, frequency, amount_type, supplier_id]
      description: |
        `financial_unit_id` y `start_date` son inmutables: si se incluyen con valor
        distinto al original, se devuelve 422. `supplier_id` sí es editable.
      properties:
        supplier_id:
          $ref: '#/components/schemas/Uuid'
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        obligation_name:
          type: string
          maxLength: 300
        description:
          type: string
          nullable: true
        base_amount:
          type: number
          exclusiveMinimum: 0
        currency:
          type: string
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
        end_date:
          type: string
          format: date
          nullable: true
        payment_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        amount_type:
          type: string
          enum: [fixed, variable]

    ChangeObligationStatusRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [active, inactive, completed]
          example: "inactive"

    # ── Income Contracts ──────────────────────────────────────────────
    IncomeContractItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        customer_id:
          $ref: '#/components/schemas/Uuid'
        responsible_user_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Responsable de cobranza (para Fase 6)
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        contract_name:
          type: string
          example: "Contrato mensual cliente X"
        description:
          type: string
          nullable: true
        base_amount:
          type: string
          description: Monto base como string (evita floating point)
          example: "150000.00"
        currency:
          type: string
          example: "CRC"
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
          example: "monthly"
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
          description: Día del mes de cobro. Null usa el día de start_date.
          example: 5
        start_date:
          type: string
          format: date
          example: "2026-01-05"
        end_date:
          type: string
          format: date
          nullable: true
          example: "2026-12-05"
        expected_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Cuenta bancaria destino (debe pertenecer a financial_unit_id)
        status:
          type: string
          enum: [active, inactive, completed]
          example: "active"
        source:
          type: string
          enum: [manual, excel, easytax]
          example: "manual"
        created_by:
          $ref: '#/components/schemas/Uuid'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    IncomeContractDetail:
      allOf:
        - $ref: '#/components/schemas/IncomeContractItem'
        - type: object
          properties:
            movements_summary:
              $ref: '#/components/schemas/MovementsSummary'

    MovementsSummary:
      type: object
      properties:
        total_generated:
          type: integer
          example: 9
        pending:
          type: integer
          example: 6
        executed:
          type: integer
          example: 2
        partial:
          type: integer
          example: 1
        cancelled:
          type: integer
          example: 0

    RegenerationResult:
      type: object
      nullable: true
      description: Present only when critical fields changed and movements were regenerated
      properties:
        cancelled:
          type: integer
          description: Pending movements cancelled
          example: 4
        created:
          type: integer
          description: New pending movements created
          example: 3

    CreateIncomeContractRequest:
      type: object
      required: [financial_unit_id, customer_id, contract_name, base_amount, frequency, start_date]
      properties:
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        customer_id:
          $ref: '#/components/schemas/Uuid'
        responsible_user_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Debe ser de tipo income o both
        contract_name:
          type: string
          maxLength: 300
          example: "Contrato mensual cliente X"
        description:
          type: string
          nullable: true
        base_amount:
          type: number
          exclusiveMinimum: 0
          example: 150000
        currency:
          type: string
          default: "CRC"
          example: "CRC"
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
          example: "monthly"
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
          description: Requerido para frecuencias monthly/bimonthly/quarterly/four_monthly/semiannual/annual. Null usa el día de start_date.
          example: 5
        start_date:
          type: string
          format: date
          example: "2026-01-05"
        end_date:
          type: string
          format: date
          nullable: true
          description: Null = indefinido. Ignorado si frequency = once.
          example: "2026-12-05"
        expected_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: Debe pertenecer a la misma financial_unit_id

    UpdateIncomeContractRequest:
      type: object
      required: [contract_name, base_amount, frequency]
      description: |
        `financial_unit_id`, `customer_id` y `start_date` son inmutables.
        Si se incluyen con un valor distinto al original, se devuelve 422.
      properties:
        contract_name:
          type: string
          maxLength: 300
        description:
          type: string
          nullable: true
        responsible_user_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        category_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        base_amount:
          type: number
          exclusiveMinimum: 0
          example: 175000
        currency:
          type: string
          example: "CRC"
        frequency:
          type: string
          enum: [once, daily, weekly, biweekly, fortnightly, monthly, bimonthly, quarterly, four_monthly, semiannual, annual]
        expected_day:
          type: integer
          nullable: true
          minimum: 1
          maximum: 31
        end_date:
          type: string
          format: date
          nullable: true
        expected_bank_account_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true

    ChangeContractStatusRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [active, inactive, completed]
          example: "inactive"

    # ── Categories ────────────────────────────────────────────────────
    CategoryItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: null si aplica a toda la organización
        name:
          type: string
          example: "Ventas"
        type:
          type: string
          enum: [income, expense, both]
          example: "income"
        color:
          type: string
          nullable: true
          example: "#4CAF50"
        sort_order:
          type: integer
          example: 1
        is_system:
          type: boolean
          description: true si fue creada por el sistema y no puede modificarse
          example: false
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CreateCategoryRequest:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          minLength: 1
          example: "Ventas"
        type:
          type: string
          enum: [income, expense, both]
          example: "income"
        financial_unit_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        color:
          type: string
          nullable: true
          pattern: '^#[0-9A-Fa-f]{6}$'
          example: "#4CAF50"
        sort_order:
          type: integer
          default: 0
          example: 1

    UpdateCategoryRequest:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          minLength: 1
          example: "Ventas actualizado"
        type:
          type: string
          enum: [income, expense, both]
          example: "both"
        financial_unit_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        color:
          type: string
          nullable: true
          pattern: '^#[0-9A-Fa-f]{6}$'
          example: "#FF5722"
        sort_order:
          type: integer
          example: 2

    # ── Customers ──────────────────────────────────────────────────────
    CustomerItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "Juan Pérez"
        identification:
          type: string
          nullable: true
          example: "1-1234-5678"
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"
        whatsapp:
          type: string
          nullable: true
          example: "+506 8888-9999"
        email:
          type: string
          format: email
          nullable: true
          example: "juan@empresa.com"
        collection_responsible_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        easytax_code:
          type: string
          nullable: true
          example: "EC-001"
        notes:
          type: string
          nullable: true
          example: "Cliente preferencial"
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CreateCustomerRequest:
      type: object
      required: [name, financial_unit_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "Juan Pérez"
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        identification:
          type: string
          nullable: true
          example: "1-1234-5678"
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"
        whatsapp:
          type: string
          nullable: true
          example: "+506 8888-9999"
        email:
          type: string
          format: email
          nullable: true
          example: "juan@empresa.com"
        collection_responsible_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        easytax_code:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
          example: "Cliente preferencial"

    UpdateCustomerRequest:
      type: object
      required: [name, financial_unit_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "Juan Pérez Actualizado"
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        identification:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        whatsapp:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        collection_responsible_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
        easytax_code:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true

    # ── Suppliers ──────────────────────────────────────────────────────
    SupplierItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "Proveedor SA"
        identification:
          type: string
          nullable: true
          example: "3-101-456789"
        phone:
          type: string
          nullable: true
          example: "+506 2222-3333"
        email:
          type: string
          format: email
          nullable: true
          example: "proveedor@empresa.com"
        easytax_code:
          type: string
          nullable: true
          example: "EC-SUP-001"
        notes:
          type: string
          nullable: true
          example: "Proveedor principal de suministros"
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CreateSupplierRequest:
      type: object
      required: [name, financial_unit_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "Proveedor SA"
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        identification:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        easytax_code:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true

    UpdateSupplierRequest:
      type: object
      required: [name, financial_unit_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "Proveedor SA Actualizado"
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        identification:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        email:
          type: string
          format: email
          nullable: true
        easytax_code:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true

    # ── Financial Units ───────────────────────────────────────────────
    FinancialUnitItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "Empresa SA"
        type:
          type: string
          enum: [empresa, persona]
          example: "empresa"
        identification:
          type: string
          nullable: true
          example: "3-101-123456"
        main_currency:
          type: string
          example: "CRC"
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CreateFinancialUnitRequest:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          minLength: 1
          example: "Empresa SA"
        type:
          type: string
          enum: [empresa, persona]
          example: "empresa"
        identification:
          type: string
          nullable: true
          example: "3-101-123456"
        main_currency:
          type: string
          default: "CRC"
          example: "CRC"

    UpdateFinancialUnitRequest:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
          minLength: 1
          example: "Empresa SA Actualizada"
        type:
          type: string
          enum: [empresa, persona]
          example: "empresa"
        identification:
          type: string
          nullable: true
          example: "3-101-123456"
        main_currency:
          type: string
          example: "USD"

    # ── Bank Accounts ──────────────────────────────────────────────────
    BankAccountItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        bank_name:
          type: string
          example: "BCR"
        account_name:
          type: string
          nullable: true
          example: "Cuenta corriente principal"
        account_number:
          type: string
          nullable: true
          example: "001-123456-7"
        iban:
          type: string
          nullable: true
          example: "CR21015108310001234567"
        currency:
          type: string
          example: "CRC"
        calculated_balance:
          type: string
          description: Saldo calculado por el sistema (string para evitar floating point)
          example: "1500000.00"
        real_balance:
          type: string
          description: Saldo real ajustado manualmente
          example: "1498500.00"
        balance_difference:
          type: string
          description: "real_balance - calculated_balance (columna GENERATED en DB)"
          example: "-1500.00"
        last_balance_update:
          type: string
          format: date-time
          nullable: true
          example: "2026-06-04T10:00:00Z"
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    CreateBankAccountRequest:
      type: object
      required: [financial_unit_id, bank_name]
      properties:
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        bank_name:
          type: string
          minLength: 1
          example: "BCR"
        account_name:
          type: string
          nullable: true
          example: "Cuenta corriente principal"
        account_number:
          type: string
          nullable: true
          example: "001-123456-7"
        iban:
          type: string
          nullable: true
          example: "CR21015108310001234567"
        currency:
          type: string
          default: "CRC"
          example: "CRC"

    UpdateBankAccountRequest:
      type: object
      required: [financial_unit_id, bank_name]
      properties:
        financial_unit_id:
          $ref: '#/components/schemas/Uuid'
        bank_name:
          type: string
          minLength: 1
          example: "BAC"
        account_name:
          type: string
          nullable: true
          example: "Cuenta de ahorros"
        account_number:
          type: string
          nullable: true
          example: "002-654321-9"
        iban:
          type: string
          nullable: true
          example: null
        currency:
          type: string
          example: "USD"

    AdjustBalanceRequest:
      type: object
      required: [real_balance]
      properties:
        real_balance:
          type: number
          minimum: 0
          description: Nuevo saldo real (>= 0)
          example: 1498500.00
        notes:
          type: string
          nullable: true
          description: Motivo del ajuste (queda en audit_log)
          example: "Conciliación bancaria junio 2026"

    # ── Users ────────────────────────────────────────────────────────
    UserItem:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "María García"
        email:
          type: string
          format: email
          example: "maria@easycash.app"
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"
        role_id:
          $ref: '#/components/schemas/Uuid'
        role:
          type: string
          description: Slug del rol asignado
          example: "operator"
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        last_login_at:
          type: string
          format: date-time
          nullable: true
          example: "2026-06-04T08:00:00Z"
        created_at:
          $ref: '#/components/schemas/Timestamp'

    CreateUserRequest:
      type: object
      required: [name, email, password, role_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "María García"
        email:
          type: string
          format: email
          example: "maria@easycash.app"
        password:
          type: string
          format: password
          minLength: 8
          example: "Secret1234!"
        role_id:
          $ref: '#/components/schemas/Uuid'
        phone:
          type: string
          example: "+506 8888-9999"

    UpdateUserRequest:
      type: object
      required: [name, role_id]
      properties:
        name:
          type: string
          minLength: 1
          example: "María García"
        role_id:
          $ref: '#/components/schemas/Uuid'
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"

    ChangeUserStatusRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [active, inactive]
          example: "inactive"

    # ── Organization ─────────────────────────────────────────────────
    OrganizationData:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "Empresa Ejemplo SA"
        identification:
          type: string
          example: "3-101-123456"
        country:
          type: string
          example: "CR"
        default_currency:
          type: string
          example: "CRC"
        status:
          type: string
          enum: [active, inactive, suspended]
          example: "active"
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'

    # ── Permission ────────────────────────────────────────────────────
    PermissionData:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        module:
          type: string
          example: "users"
        action:
          type: string
          example: "read"
        slug:
          type: string
          description: |
            Slug único del permiso. Los 72 slugs del sistema siguen el patrón
            `{module}.{action}`. Módulos disponibles: organizations,
            financial_units, consolidated_groups, bank_accounts, categories,
            exchange_rates, customers, suppliers, income_contracts,
            expense_obligations, projected_movements, movement_executions,
            manual_movements, predefined_concepts, loans, alerts, scenarios,
            dashboard, collections, pending_payments, users, roles, audit_log.
          example: "users.read"
        description:
          type: string
          nullable: true
          example: "Read users"

    # ── Role ──────────────────────────────────────────────────────────
    RoleData:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "Administrador"
        slug:
          type: string
          example: "admin"
        description:
          type: string
          nullable: true
          example: "Acceso completo al sistema."
        is_system:
          type: boolean
          description: true si es un rol predefinido del sistema, false si es personalizado
          example: true
        organization_id:
          allOf:
            - $ref: '#/components/schemas/Uuid'
          nullable: true
          description: null para roles del sistema; UUID de la org para roles personalizados
          example: null

    RoleDetailData:
      allOf:
        - $ref: '#/components/schemas/RoleData'
        - type: object
          properties:
            permissions:
              type: array
              description: Slugs de permisos asignados al rol
              items:
                type: string
              example: ["audit_log.read", "roles.create", "roles.read", "users.create", "users.read"]

    # ── Health ───────────────────────────────────────────────────────
    HealthData:
      type: object
      properties:
        status:
          type: string
          example: "ok"
        app:
          type: string
          example: "EasyCash"
        version:
          type: string
          example: "1.0.0"
        env:
          type: string
          example: "development"
        timestamp:
          type: string
          format: date-time
          example: "2026-06-04T10:30:00+00:00"
        services:
          type: object
          properties:
            database:
              type: string
              enum: [ok, error]
              example: "ok"

    # ── Auth — requests ──────────────────────────────────────────────
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: "admin@easycash.app"
        password:
          type: string
          format: password
          minLength: 1
          example: "secret123"

    UpdateProfileRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          example: "María García"
        phone:
          type: string
          example: "+506 8888-9999"

    ChangePasswordRequest:
      type: object
      required: [current_password, new_password, confirm_password]
      properties:
        current_password:
          type: string
          format: password
          minLength: 1
          example: "old_secret123"
        new_password:
          type: string
          format: password
          minLength: 8
          example: "new_secret456"
        confirm_password:
          type: string
          format: password
          minLength: 8
          example: "new_secret456"

    # ── Auth — responses ─────────────────────────────────────────────
    LoginData:
      type: object
      properties:
        access_token:
          type: string
          description: JWT de acceso (expira en 15 minutos)
          example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
        token_type:
          type: string
          example: "Bearer"
        expires_in:
          type: integer
          description: Segundos hasta expiración del access_token
          example: 900
        user:
          $ref: '#/components/schemas/UserProfile'

    RefreshData:
      type: object
      properties:
        access_token:
          type: string
          example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
        token_type:
          type: string
          example: "Bearer"
        expires_in:
          type: integer
          example: 900

    # ── User ─────────────────────────────────────────────────────────
    UserProfile:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "María García"
        email:
          type: string
          format: email
          example: "maria@easycash.app"
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"
        role:
          type: string
          example: "admin"
        organization_id:
          $ref: '#/components/schemas/Uuid'
        status:
          type: string
          enum: [active, inactive]
          example: "active"
        last_login_at:
          type: string
          format: date-time
          nullable: true
          example: "2026-06-04T08:00:00Z"
        permissions:
          type: array
          items:
            type: string
          example: ["users.view", "users.create"]

    UserBasic:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
        name:
          type: string
          example: "María García"
        email:
          type: string
          format: email
          example: "maria@easycash.app"
        phone:
          type: string
          nullable: true
          example: "+506 8888-9999"

    # ── Tipos base ───────────────────────────────────────────────────
    Uuid:
      type: string
      format: uuid
      example: "550e8400-e29b-41d4-a716-446655440000"

    Timestamp:
      type: string
      format: date-time
      example: "2026-06-04T10:30:00Z"

    DashboardMovementStatusBreakdown:
      type: object
      description: Conteo y monto por effective_status
      properties:
        amount:
          type: string
          format: decimal
          example: "60000.00"
        count:
          type: integer
          example: 8

    DashboardMovementBlock:
      type: object
      description: Totales de income o expenses para una moneda
      properties:
        total:
          type: string
          format: decimal
          example: "85000.00"
        count:
          type: integer
          example: 12
        by_status:
          type: object
          properties:
            pending:
              $ref: '#/components/schemas/DashboardMovementStatusBreakdown'
            overdue:
              $ref: '#/components/schemas/DashboardMovementStatusBreakdown'
            partial:
              $ref: '#/components/schemas/DashboardMovementStatusBreakdown'
            executed:
              $ref: '#/components/schemas/DashboardMovementStatusBreakdown'

    DashboardCurrencyBlock:
      type: object
      description: Resumen financiero para una moneda específica
      properties:
        opening_balance:
          type: string
          format: decimal
          example: "150000.00"
          description: Suma de real_balance de las cuentas bancarias filtradas
        income:
          $ref: '#/components/schemas/DashboardMovementBlock'
        expenses:
          $ref: '#/components/schemas/DashboardMovementBlock'
        net_flow:
          type: string
          format: decimal
          example: "40000.00"
          description: income.total - expenses.total
        expected_closing_balance:
          type: string
          format: decimal
          example: "190000.00"
          description: opening_balance + net_flow

    DashboardFiltersApplied:
      type: object
      properties:
        financial_unit_id:
          type: string
          format: uuid
          nullable: true
        bank_account_id:
          type: string
          format: uuid
          nullable: true
        currency:
          type: string
          nullable: true
          example: "CRC"

    DashboardPeriod:
      type: object
      properties:
        date_from:
          type: string
          format: date
          example: "2026-06-01"
        date_to:
          type: string
          format: date
          example: "2026-06-30"

    DashboardSingleCurrency:
      type: object
      description: Respuesta cuando currency sí se envía como filtro
      properties:
        period:
          $ref: '#/components/schemas/DashboardPeriod'
        filters_applied:
          $ref: '#/components/schemas/DashboardFiltersApplied'
        currency:
          type: string
          example: "CRC"
        opening_balance:
          type: string
          format: decimal
          example: "150000.00"
        income:
          $ref: '#/components/schemas/DashboardMovementBlock'
        expenses:
          $ref: '#/components/schemas/DashboardMovementBlock'
        net_flow:
          type: string
          format: decimal
          example: "40000.00"
        expected_closing_balance:
          type: string
          format: decimal
          example: "190000.00"

    DashboardMultiCurrency:
      type: object
      description: Respuesta cuando currency NO se envía — breakdown por moneda
      properties:
        period:
          $ref: '#/components/schemas/DashboardPeriod'
        filters_applied:
          $ref: '#/components/schemas/DashboardFiltersApplied'
        currencies:
          type: object
          description: Mapa moneda → bloque financiero (ej. CRC, USD)
          additionalProperties:
            $ref: '#/components/schemas/DashboardCurrencyBlock'
          example:
            CRC:
              opening_balance: "150000.00"
              income:
                total: "85000.00"
                count: 12
                by_status:
                  pending:  { amount: "60000.00", count: 8 }
                  overdue:  { amount: "10000.00", count: 2 }
                  partial:  { amount: "5000.00",  count: 1 }
                  executed: { amount: "10000.00", count: 1 }
              expenses:
                total: "45000.00"
                count: 7
                by_status:
                  pending:  { amount: "30000.00", count: 5 }
                  overdue:  { amount: "5000.00",  count: 1 }
                  partial:  { amount: "0.00",     count: 0 }
                  executed: { amount: "10000.00", count: 1 }
              net_flow: "40000.00"
              expected_closing_balance: "190000.00"
            USD:
              opening_balance: "500.00"
              income:
                total: "1000.00"
                count: 2
                by_status:
                  pending:  { amount: "1000.00", count: 2 }
                  overdue:  { amount: "0.00",    count: 0 }
                  partial:  { amount: "0.00",    count: 0 }
                  executed: { amount: "0.00",    count: 0 }
              expenses:
                total: "300.00"
                count: 1
                by_status:
                  pending:  { amount: "300.00", count: 1 }
                  overdue:  { amount: "0.00",   count: 0 }
                  partial:  { amount: "0.00",   count: 0 }
                  executed: { amount: "0.00",   count: 0 }
              net_flow: "700.00"
              expected_closing_balance: "1200.00"

  responses:
    Unauthorized:
      description: Token inválido o expirado
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    Forbidden:
      description: Sin permisos suficientes
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    NotFound:
      description: Recurso no encontrado
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    UnprocessableEntity:
      description: Error de validación
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationErrorResponse'

    Conflict:
      description: Conflicto de estado (ej. optimistic locking)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

security:
  - bearerAuth: []
