openapi: 3.0.3
info:
  title: TimbraMX API
  description: >
    API REST para timbrar, consultar y cancelar CFDI 4.0 con TimbraMX.
    Autenticación por API key (header `X-Api-Key`). Cada cuenta tiene dos
    keys separadas: una de **sandbox** (pruebas, sin costo, sin valor
    fiscal, vence a los 90 días de inactividad) y una de **producción**
    (solo disponible una vez que tu cuenta está certificada). El ambiente
    lo determina la key usada, no la URL: ambas keys llaman a las mismas
    rutas.


    **Sin webhooks en v1** - usa polling (GET /:id) para detectar cambios de
    estado. **Sin filtros por RFC/UUID/fecha** en los listados (solo
    pagina/limite, y tipo en Retenciones) - guarda el `id` que regresa cada
    POST si necesitas buscarlo después. Contrato de campos completo,
    ejemplos de error y guía de reintentos en
    https://timbra-mx.com/docs/api/llms-full.txt.
  version: "1.0.0"
  contact:
    name: TimbraMX
    url: https://timbra-mx.com/paginas/contacto.html
servers:
  - url: https://app.timbra-mx.com/api/v1
    description: Producción y sandbox (el ambiente lo decide la API key usada)

security:
  - ApiKeyAuth: []

tags:
  - name: Timbrado
    description: Timbrar un CFDI (modo relay o modo sellar) y consultarlo
  - name: Cancelación
    description: Cancelar un CFDI ya timbrado ante el SAT
  - name: Cuenta
    description: Consultar tu saldo de timbres disponibles
  - name: Notas de crédito
    description: Acreditar, total o parcialmente, una factura ya timbrada
  - name: Complementos de pago
    description: Timbrar el CFDI de Pago (tipo P) para facturas PPD
  - name: Nómina
    description: Timbrar el CFDI de Nómina 1.2 (tipo N)
  - name: Retenciones
    description: Timbrar Retenciones e Información de Pagos (dividendos, enajenación de acciones, pagos al extranjero)

paths:
  /facturas/validar:
    post:
      tags: [Timbrado]
      summary: Validar un CFDI sin timbrarlo
      description: >
        Solo modo relay: valida el XML contra el XSD del SAT y contra reglas
        de negocio que el XSD no expresa (las mismas que aplica TimbraMX
        internamente antes de timbrar en modo sellar), sin gastar ningún
        intento real con el PAC ni consumir timbres. Útil para probar tu
        integración antes de mandar la petición real de timbrado.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [xml]
              properties:
                xml: { type: string, format: byte, description: CFDI ya sellado, en base64 (mismo formato que el modo relay de POST /facturas). }
      responses:
        '200':
          description: Siempre 200 - un XML inválido es un resultado de la validación, no un error de la petición.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      valido: { type: boolean }
                      errores:
                        type: array
                        items: { type: string }
                        example: ["[VAL-04] Falta capturar \"RFC\" en Receptor."]
        '400':
          description: Falta el XML, o no es un base64 válido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /facturas:
    post:
      tags: [Timbrado]
      summary: Timbrar un CFDI
      description: >
        Acepta dos modos, detectados por la forma del body (son excluyentes):

        - **Modo relay**: mandas `xml` con el CFDI que TÚ ya sellaste con tu
          propio CSD, en base64. TimbraMX solo lo timbra ante el SAT.
        - **Modo sellar**: mandas `receptorRfc`, `serie` y `conceptos`.
          TimbraMX arma el XML, lo sella con el CSD que ya subiste al portal,
          y lo timbra. Solo CFDI de Ingreso simple en v1 (sin complementos).

        Para reintentos seguros, manda el header `Idempotency-Key` (o el
        campo `idempotency_key` en el body): si ya existe una factura tuya
        con esa misma clave, se regresa el comprobante original en vez de
        timbrar/cobrar otra vez.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: Clave única (por tu cuenta) para hacer este POST idempotente.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/TimbrarRelayRequest'
                - $ref: '#/components/schemas/TimbrarSellarRequest'
            examples:
              relay:
                summary: Modo relay (XML ya sellado)
                value:
                  xml: "PHNmZGk6Q29tcHJvYmFudGUgeG1sbnM6Y2ZkaT0i..."
              sellar:
                summary: Modo sellar (TimbraMX arma y sella)
                value:
                  receptorRfc: "XAXX010101000"
                  serie: "F"
                  metodoPago: "PUE"
                  moneda: "MXN"
                  conceptos:
                    - claveProdServ: "81112101"
                      claveUnidad: "E48"
                      descripcion: "Servicio de consultoría"
                      cantidad: 1
                      valorUnitario: 1000
                      impuestos:
                        - impuesto: "002"
                          categoria: "traslado"
                          tipoFactor: "Tasa"
                          tasaIva: 0.16
      responses:
        '201':
          description: CFDI timbrado con éxito.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ComprobanteResponse'
        '200':
          description: Petición idempotente - ya existía una factura con ese Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ComprobanteResponse'
        '400':
          description: Datos inválidos (falta el XML, faltan campos del modo sellar, RFC/serie inexistentes, etc.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Falta el header X-Api-Key, la key es inválida, o la key de sandbox ya expiró.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '402':
          description: No tienes timbres disponibles (solo aplica en producción; sandbox nunca cobra).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Tu cuenta no tiene contratado el módulo de API, o la key de producción no está certificada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Límite de peticiones excedido (60/min por cuenta, independiente entre sandbox y producción).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: El PAC rechazó el timbrado (ver "codigo" y "detalle" en la respuesta).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    get:
      tags: [Timbrado]
      summary: Listar tus facturas
      description: Solo regresa las facturas del mismo ambiente que tu API key (sandbox o producción, nunca mezcladas).
      parameters:
        - name: pagina
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limite
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: estado
          in: query
          description: "Filtra por estado exacto: borrador, error, timbrada, cancelacion_pendiente, cancelada."
          schema: { type: string }
        - name: uuid
          in: query
          description: Búsqueda parcial (contains) sobre el UUID fiscal.
          schema: { type: string }
        - name: rfc
          in: query
          description: Búsqueda parcial (contains) sobre el RFC del receptor.
          schema: { type: string }
        - name: desde
          in: query
          description: Fecha de creación mínima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: hasta
          in: query
          description: Fecha de creación máxima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ComprobanteResumen' }
                  total: { type: integer }
                  pagina: { type: integer }
                  limite: { type: integer }
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /facturas/saldo:
    get:
      tags: [Cuenta]
      summary: Consultar tu saldo de timbres
      description: >
        El saldo es el mismo, real, de producción, sin importar con cuál de
        las dos keys consultes - el ambiente sandbox nunca descuenta
        timbres, pero saber cuántos quedan es útil para decidir cuándo pasar
        a producción.
      responses:
        '200':
          description: Saldo disponible.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      saldo: { type: integer, example: 1234, description: Timbres disponibles ahora mismo. }
                      proximaVigencia: { type: string, format: date-time, nullable: true, description: Fecha de vencimiento del lote vigente más próximo a expirar. }
                      esSandbox: { type: boolean, description: Con cuál key se consultó. }
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /facturas/{id}:
    get:
      tags: [Timbrado]
      summary: Consultar una factura por su id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Detalle de la factura.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/ComprobanteResumen' }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No existe, o pertenece a otro ambiente/cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/xml:
    get:
      tags: [Timbrado]
      summary: Descargar el XML timbrado crudo
      description: Si la factura ya tiene una addenda anexada, regresa el XML con la addenda incluida (el definitivo).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Archivo XML (no JSON, no base64).
          content:
            application/xml:
              schema: { type: string }
        '400':
          description: La factura todavía no tiene ningún XML generado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No existe, o pertenece a otro ambiente/cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/pdf:
    get:
      tags: [Timbrado]
      summary: Descargar el PDF de una factura
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Archivo PDF (no JSON) con el diseño configurado en tu cuenta.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No existe, pertenece a otro ambiente/cuenta, o todavía no está timbrada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/acuse:
    get:
      tags: [Cancelación]
      summary: Consultar el acuse de cancelación
      description: Solo existe si la factura ya fue cancelada.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Acuse de cancelación en base64.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      acuse: { type: string, format: byte, description: XML del acuse de cancelación del SAT, en base64. }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No existe, pertenece a otro ambiente/cuenta, o la factura no ha sido cancelada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/estatus-sat:
    get:
      tags: [Cancelación]
      summary: Consultar el estatus ante el SAT (en vivo)
      description: Pregunta directo al SAT si el CFDI sigue vigente o ya fue cancelado - distinto del "estado" guardado localmente en TimbraMX. Útil si otro sistema o el receptor solicitó la cancelación fuera de TimbraMX.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Estatus reportado por el SAT.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      uuid: { type: string, format: uuid }
                      estado: { type: string, enum: [Vigente, Cancelado, "No Encontrado"] }
        '400':
          description: La factura todavía no ha sido timbrada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No existe, o pertenece a otro ambiente/cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '502':
          description: No se pudo consultar el estatus ante el SAT en este momento.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/cancelar:
    post:
      tags: [Cancelación]
      summary: Cancelar un CFDI ya timbrado
      description: >
        Usa el catálogo oficial del SAT de motivos de cancelación. Si dos
        peticiones de cancelación para el mismo comprobante llegan casi
        simultáneas, solo una se procesa - la otra recibe 409 (protección
        contra ráfagas de reintentos duplicados).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [motivo]
              properties:
                motivo:
                  type: string
                  enum: ["01", "02", "03", "04"]
                  description: "Catálogo c_MotivoCancelacion del SAT: 01 con relación, 02 sin relación, 03 no se llevó a cabo, 04 factura global nominativa."
                uuidReemplazo:
                  type: string
                  format: uuid
                  description: Solo si motivo es "01" - UUID del CFDI que sustituye a este.
      responses:
        '200':
          description: Cancelación procesada (revisa "estado" en la respuesta - puede quedar "cancelada" o, si el SAT aún no confirma, en un estado transitorio).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/ComprobanteResumen' }
        '400':
          description: Motivo inválido, falta uuidReemplazo con motivo 01, o falta el CSD de la cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: Factura no encontrada en tu ambiente/cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Ya hay otra cancelación en curso para este mismo comprobante - reintenta en unos segundos.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/estatus-cancelacion:
    get:
      tags: [Cancelación]
      summary: Confirmar una cancelación pendiente (sin volver a solicitarla)
      description: >
        Refresca el estatus real ante el PAC de un documento en
        cancelacion_pendiente. Si ya se confirmó, actualiza estado a
        cancelada en la misma llamada. Unificado en los 4 recursos
        (Facturas, Complementos de pago, Nómina y Retenciones).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Estatus reportado por el PAC.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/ComprobanteResumen' }
                  confirmada: { type: boolean, description: "true si esta consulta acaba de confirmar la cancelación." }
        '400':
          description: El documento existe pero no tiene una cancelación pendiente de confirmar.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404':
          description: No encontrado, o pertenece a otro ambiente/cuenta.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /facturas/{id}/nota-credito:
    post:
      tags: [Notas de crédito]
      summary: Generar una nota de crédito de una factura ya timbrada
      description: >
        Acredita, total o parcialmente, una factura de Ingreso ya timbrada
        por esta misma API key. La forma de pago se copia de la factura
        original (regla del Anexo 20) y el método de pago siempre es PUE.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: Id (no el UUID fiscal) de la factura original.
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [importe, descripcion]
              properties:
                importe: { type: number, exclusiveMinimum: 0, description: No puede ser mayor al total de la factura original. }
                descripcion: { type: string, maxLength: 1000, example: "Devolución de mercancía" }
                serie: { type: string, description: Código de una serie tipo Notas de crédito. Si se omite, se usa la favorita. }
      responses:
        '201':
          description: Nota de crédito timbrada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComprobanteResponse' }
        '400':
          description: "Importe/descripción inválidos ([NC-002], [NC-003]), factura no timbrada, o no hay serie activa de tipo nota_credito."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '402': { description: Sin timbres disponibles., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '404':
          description: Factura no encontrada en tu ambiente/cuenta.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /complementos-pago:
    post:
      tags: [Complementos de pago]
      summary: Timbrar un Complemento de Pago (CFDI tipo P)
      description: >
        Las facturas a cubrir se identifican por su UUID fiscal
        (`facturaUuid`), deben ser PPD, estar timbradas, y ser del mismo
        receptor. Un mismo `pago` puede cubrir varios documentos, y el body
        puede traer varios `pagos` en un solo complemento.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ComplementoPagoRequest' }
            example:
              receptorRfc: "COE850101AB1"
              serie: "PAGOS"
              pagos:
                - fechaPago: "2026-09-15T12:00:00"
                  formaPago: "03"
                  moneda: "MXN"
                  documentos:
                    - facturaUuid: "3F9A1C2E-4B7D-4E3A-9C2F-8B1D6A5E7F90"
                      impPagado: 12500.00
      responses:
        '201':
          description: Complemento de pago timbrado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      estado: { type: string, example: timbrada }
                      facturaId: { type: string, format: uuid, description: Id del CFDI tipo P generado. }
        '400':
          description: "Datos inválidos (ver prefijos [PAG-xxx]), RFC/serie inexistentes, o facturaUuid que no es PPD timbrada de ese receptor."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '402': { description: Sin timbres disponibles., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
    get:
      tags: [Complementos de pago]
      summary: Listar tus complementos de pago
      parameters:
        - name: pagina
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limite
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: estado
          in: query
          description: "Filtra por estado exacto: borrador, error, timbrada."
          schema: { type: string }
        - name: uuid
          in: query
          description: Búsqueda parcial (contains) sobre el UUID fiscal del CFDI tipo P asociado.
          schema: { type: string }
        - name: rfc
          in: query
          description: Búsqueda parcial (contains) sobre el RFC del receptor.
          schema: { type: string }
        - name: desde
          in: query
          description: Fecha de creación mínima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: hasta
          in: query
          description: Fecha de creación máxima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
      responses:
        '200':
          description: Listado paginado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  data: { type: array, items: { type: object } }
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /complementos-pago/{id}:
    get:
      tags: [Complementos de pago]
      summary: Consultar un complemento de pago por su id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Detalle del complemento (incluye sus pagos y las facturas cubiertas). }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No existe, o pertenece a otro ambiente/cuenta., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /complementos-pago/{id}/xml:
    get:
      tags: [Complementos de pago]
      summary: Descargar el XML timbrado
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo XML (no JSON)., content: { application/xml: { schema: { type: string } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /complementos-pago/{id}/pdf:
    get:
      tags: [Complementos de pago]
      summary: Descargar el PDF
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo PDF (no JSON)., content: { application/pdf: { schema: { type: string, format: binary } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /complementos-pago/{id}/cancelar:
    post:
      tags: [Complementos de pago]
      summary: Cancelar el CFDI tipo P
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [motivo]
              properties:
                motivo: { type: string, enum: ["01", "02", "03", "04"] }
                uuidReemplazo: { type: string, format: uuid }
      responses:
        '200': { description: Cancelación procesada. }
        '400': { description: Motivo inválido o falta el CSD., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '409': { description: Ya hay otra cancelación en curso., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /complementos-pago/{id}/estatus-cancelacion:
    get:
      tags: [Complementos de pago]
      summary: Confirmar una cancelación pendiente (sin volver a solicitarla)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Estatus reportado por el PAC - si ya se confirmó, actualiza estado a cancelada. }
        '400': { description: El documento existe pero no tiene una cancelación pendiente de confirmar., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado, o pertenece a otro ambiente/cuenta., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /nomina:
    post:
      tags: [Nómina]
      summary: Timbrar un Recibo de Nómina (CFDI tipo N)
      description: >
        Arma, sella y timbra el CFDI de Nómina 1.2 en un solo paso. El
        `receptorRfc` debe existir en tu catálogo de clientes. Todos los
        campos numéricos del nodo `nomina` viajan como **string**, igual
        que exige el XSD del SAT. Este endpoint siempre timbra (no guarda
        borrador; para eso usa el portal).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NominaRequest' }
            example:
              receptorRfc: "MARL900101AB2"
              serie: "NOM"
              nomina:
                tipoNomina: "O"
                fechaPago: "2026-09-15"
                fechaInicialPago: "2026-09-01"
                fechaFinalPago: "2026-09-15"
                numDiasPagados: "15"
                totalPercepciones: "7500.00"
                receptor:
                  curp: "MARL900101HDFRZS02"
                  tipoContrato: "01"
                  tipoRegimen: "02"
                  numEmpleado: "1"
                  periodicidadPago: "04"
                  claveEntFed: "JAL"
                percepciones:
                  totalGravado: "7500.00"
                  totalExento: "0.00"
                  percepciones:
                    - tipoPercepcion: "001"
                      clave: "001"
                      concepto: "Sueldos"
                      importeGravado: "7500.00"
                      importeExento: "0.00"
      responses:
        '201':
          description: Recibo de nómina timbrado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComprobanteResponse' }
        '400':
          description: "Datos inválidos - ver prefijos [FC-xxx] (p.ej. [FC-219] falta TotalPercepciones/TotalOtrosPagos, [FC-220] fechas invertidas)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '402': { description: Sin timbres disponibles., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
    get:
      tags: [Nómina]
      summary: Listar tus recibos de nómina
      parameters:
        - name: pagina
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limite
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: estado
          in: query
          description: "Filtra por estado exacto: borrador, error, timbrada, cancelacion_pendiente, cancelada."
          schema: { type: string }
        - name: uuid
          in: query
          description: Búsqueda parcial (contains) sobre el UUID fiscal.
          schema: { type: string }
        - name: rfc
          in: query
          description: Búsqueda parcial (contains) sobre el RFC del receptor.
          schema: { type: string }
        - name: desde
          in: query
          description: Fecha de creación mínima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: hasta
          in: query
          description: Fecha de creación máxima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
      responses:
        '200': { description: Listado paginado. }
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /nomina/{id}:
    get:
      tags: [Nómina]
      summary: Consultar un recibo de nómina por su id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Detalle del recibo. }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /nomina/{id}/xml:
    get:
      tags: [Nómina]
      summary: Descargar el XML timbrado
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo XML (no JSON)., content: { application/xml: { schema: { type: string } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /nomina/{id}/pdf:
    get:
      tags: [Nómina]
      summary: Descargar el PDF
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo PDF (no JSON)., content: { application/pdf: { schema: { type: string, format: binary } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /nomina/{id}/cancelar:
    post:
      tags: [Nómina]
      summary: Cancelar el CFDI de Nómina
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [motivo]
              properties:
                motivo: { type: string, enum: ["01", "02", "03", "04"] }
                uuidReemplazo: { type: string, format: uuid }
      responses:
        '200': { description: Cancelación procesada. }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /nomina/{id}/estatus-cancelacion:
    get:
      tags: [Nómina]
      summary: Confirmar una cancelación pendiente (sin volver a solicitarla)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Estatus reportado por el PAC - si ya se confirmó, actualiza estado a cancelada. }
        '400': { description: El documento existe pero no tiene una cancelación pendiente de confirmar., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado, o pertenece a otro ambiente/cuenta., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /retenciones:
    post:
      tags: [Retenciones]
      summary: Timbrar Retenciones e Información de Pagos
      description: >
        Alcance v1: solo 3 de los 12 tipos del catálogo (`dividendos`,
        `enajenacion_acciones`, `pagos_extranjero`). Con el RFC genérico
        `XAXX010101000`, `nombreContraparte` debe ser exactamente
        "PÚBLICO EN GENERAL" (con acento) - el PAC lo rechaza sin el acento.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RetencionRequest' }
            example:
              tipoOperacion: "dividendos"
              serie: "RET"
              rfcContraparte: "MARL900101AB2"
              nombreContraparte: "Luis Martinez Rodriguez"
              codigoPostalContraparte: "44100"
              fechaOperacion: "2026-09-15"
              montoOperacion: 50000
              cveRetenc: "14"
              mesPeriodo: 9
              ejercicio: 2026
              impRetenidos:
                - impuesto: "001"
                  montoRet: 5000
                  tipoPagoRet: "02"
      responses:
        '201':
          description: Retención timbrada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ComprobanteResponse' }
        '400':
          description: "Datos inválidos - ver prefijos [RET-xxx] (p.ej. [RET-010] nombre incorrecto para RFC genérico, [RET-015] país inexistente)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '402': { description: Sin timbres disponibles., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
    get:
      tags: [Retenciones]
      summary: Listar tus retenciones
      parameters:
        - name: tipo
          in: query
          schema: { type: string, enum: [dividendos, enajenacion_acciones, pagos_extranjero] }
        - name: estado
          in: query
          description: "Filtra por estado exacto: borrador, error, timbrada, cancelacion_pendiente, cancelada."
          schema: { type: string }
        - name: uuid
          in: query
          description: Búsqueda parcial (contains) sobre el UUID fiscal.
          schema: { type: string }
        - name: rfc
          in: query
          description: Búsqueda parcial (contains) sobre el RFC de la contraparte.
          schema: { type: string }
        - name: desde
          in: query
          description: Fecha de creación mínima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: hasta
          in: query
          description: Fecha de creación máxima, formato YYYY-MM-DD.
          schema: { type: string, format: date }
        - name: pagina
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limite
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200': { description: Listado paginado. }
        '401': { $ref: '#/components/responses/NoAutorizado' }

  /retenciones/{id}:
    get:
      tags: [Retenciones]
      summary: Consultar una retención por su id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Detalle de la retención. }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /retenciones/{id}/xml:
    get:
      tags: [Retenciones]
      summary: Descargar el XML timbrado
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo XML (no JSON)., content: { application/xml: { schema: { type: string } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /retenciones/{id}/pdf:
    get:
      tags: [Retenciones]
      summary: Descargar el PDF
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Archivo PDF (no JSON)., content: { application/pdf: { schema: { type: string, format: binary } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /retenciones/{id}/cancelar:
    post:
      tags: [Retenciones]
      summary: Cancelar la retención
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [motivo]
              properties:
                motivo: { type: string, enum: ["01", "02", "03", "04"] }
                uuidReemplazo: { type: string, format: uuid }
      responses:
        '200': { description: Cancelación procesada (puede quedar en estatus transitorio hasta confirmarse). }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /retenciones/{id}/estatus-cancelacion:
    get:
      tags: [Retenciones]
      summary: Consultar el estatus de una cancelación en curso
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Estatus reportado por el PAC - si ya se confirmó, actualiza estado a cancelada en la misma llamada. }
        '400': { description: El documento existe pero no tiene una cancelación pendiente de confirmar., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '401': { $ref: '#/components/responses/NoAutorizado' }
        '404': { description: No encontrado, o pertenece a otro ambiente/cuenta., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: >
        API key de sandbox o de producción. El servidor detecta contra
        cuál de las dos coincide y responde en ese ambiente.

  responses:
    NoAutorizado:
      description: Falta el header X-Api-Key o la key es inválida/expiró.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    ImpuestoConcepto:
      type: object
      properties:
        impuesto: { type: string, enum: ["001", "002", "003"], default: "002", description: "001 ISR, 002 IVA, 003 IEPS (catálogo SAT c_Impuesto)." }
        categoria: { type: string, enum: [traslado, retencion], default: traslado }
        tipoFactor: { type: string, enum: [Tasa, Exento], default: Tasa }
        tasaIva: { type: number, minimum: 0, maximum: 1, example: 0.16 }

    Concepto:
      type: object
      required: [claveProdServ, claveUnidad, descripcion, cantidad, valorUnitario]
      properties:
        claveProdServ: { type: string, minLength: 6, description: Catálogo SAT c_ClaveProdServ. }
        claveUnidad: { type: string, minLength: 2, description: Catálogo SAT c_ClaveUnidad. }
        descripcion: { type: string, maxLength: 1000 }
        cantidad: { type: number, exclusiveMinimum: 0 }
        valorUnitario: { type: number, minimum: 0 }
        descuento: { type: number, minimum: 0 }
        impuestos:
          type: array
          maxItems: 10
          items: { $ref: '#/components/schemas/ImpuestoConcepto' }

    TimbrarRelayRequest:
      type: object
      required: [xml]
      properties:
        xml: { type: string, format: byte, description: CFDI ya sellado con tu propio CSD, codificado en base64. }
        idempotency_key: { type: string, maxLength: 255 }

    TimbrarSellarRequest:
      type: object
      required: [receptorRfc, serie, conceptos]
      properties:
        receptorRfc: { type: string, minLength: 12, maxLength: 13, description: RFC de un cliente ya dado de alta en tu catálogo "Mis clientes". }
        serie: { type: string, description: Código de una serie de facturas activa (ej. "F"). }
        formaPago: { type: string, minLength: 2, maxLength: 2, description: Catálogo SAT c_FormaPago. }
        metodoPago: { type: string, enum: [PUE, PPD], default: PUE }
        moneda: { type: string, default: MXN }
        conceptos:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: '#/components/schemas/Concepto' }
        idempotency_key: { type: string, maxLength: 255 }

    ComprobanteResumen:
      type: object
      properties:
        id: { type: string, format: uuid }
        pac: { type: string, example: "..." }
        estado: { type: string, enum: [borrador, timbrando, error, timbrada, cancelando, cancelacion_pendiente, cancelada], description: "Ver el ciclo de vida completo en llms-full.txt. cancelacion_pendiente ocurre cuando el SAT exige aprobación del receptor - confírmala con GET /{id}/estatus-cancelacion (disponible en los 4 recursos)." }
        serie: { type: string, nullable: true }
        folio: { type: string, nullable: true }
        uuidFiscal: { type: string, format: uuid, nullable: true }
        total: { type: number, nullable: true }
        errorCodigo: { type: string, nullable: true }
        errorMensaje: { type: string, nullable: true }
        creadaEn: { type: string, format: date-time }
        timbradaEn: { type: string, format: date-time, nullable: true }

    ComprobanteResponse:
      type: object
      properties:
        ok: { type: boolean, example: true }
        idempotente: { type: boolean, description: Presente y en true si esta respuesta viene de una petición repetida con el mismo Idempotency-Key. }
        data:
          type: object
          properties:
            id: { type: string, format: uuid }
            estado: { type: string, example: timbrada }
            uuidFiscal: { type: string, format: uuid }
            pac: { type: string, example: "..." }
            timbradaEn: { type: string, format: date-time }
            xml: { type: string, format: byte, description: CFDI timbrado completo, en base64. }

    DocumentoPago:
      type: object
      required: [facturaUuid, impPagado]
      properties:
        facturaUuid: { type: string, format: uuid, description: UUID fiscal de una factura PPD ya timbrada del mismo receptor. }
        impPagado: { type: number, exclusiveMinimum: 0 }

    Pago:
      type: object
      required: [fechaPago, formaPago, documentos]
      properties:
        fechaPago: { type: string, format: date-time }
        formaPago: { type: string, pattern: '^\d{2}$', description: Catálogo SAT c_FormaPago. }
        moneda: { type: string, default: MXN }
        tipoCambio: { type: number, exclusiveMinimum: 0, description: Obligatorio si moneda no es MXN. }
        numOperacion: { type: string, maxLength: 100 }
        rfcEmisorCtaOrd: { type: string, maxLength: 13 }
        nomBancoOrdExt: { type: string, maxLength: 300 }
        ctaOrdenante: { type: string, maxLength: 50 }
        rfcEmisorCtaBen: { type: string, maxLength: 13 }
        ctaBeneficiario: { type: string, maxLength: 50 }
        documentos:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/DocumentoPago' }

    ComplementoPagoRequest:
      type: object
      required: [receptorRfc, pagos]
      properties:
        receptorRfc: { type: string, description: RFC de un cliente ya dado de alta en "Mis clientes". }
        serie: { type: string, description: Código de una serie tipo complemento_pago. Si se omite, se usa la favorita. }
        pagos:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Pago' }
        idempotency_key: { type: string, maxLength: 255 }

    NominaPercepcion:
      type: object
      required: [tipoPercepcion, clave, concepto, importeGravado, importeExento]
      properties:
        tipoPercepcion: { type: string, description: Catálogo SAT c_TipoPercepcion. }
        clave: { type: string, minLength: 3, maxLength: 15 }
        concepto: { type: string, maxLength: 100 }
        importeGravado: { type: string, description: "Numérico como string, formato del XSD del SAT." }
        importeExento: { type: string }

    NominaReceptor:
      type: object
      required: [curp, tipoContrato, tipoRegimen, numEmpleado, periodicidadPago, claveEntFed]
      properties:
        curp: { type: string, minLength: 18, maxLength: 18 }
        numSeguridadSocial: { type: string }
        tipoContrato: { type: string, description: Catálogo SAT c_TipoContrato. }
        tipoRegimen: { type: string, description: Catálogo SAT c_TipoRegimen. }
        numEmpleado: { type: string, maxLength: 15 }
        periodicidadPago: { type: string, description: Catálogo SAT c_PeriodicidadPago. }
        claveEntFed: { type: string, description: Catálogo SAT c_Estado. }
        departamento: { type: string }
        puesto: { type: string }
        salarioDiarioIntegrado: { type: string }

    NominaDatos:
      type: object
      required: [tipoNomina, fechaPago, fechaInicialPago, fechaFinalPago, numDiasPagados, receptor]
      properties:
        tipoNomina: { type: string, enum: [O, E], description: Ordinaria o Extraordinaria. }
        fechaPago: { type: string, format: date }
        fechaInicialPago: { type: string, format: date }
        fechaFinalPago: { type: string, format: date }
        numDiasPagados: { type: string }
        totalPercepciones: { type: string, description: Obligatorio si no hay totalOtrosPagos. }
        totalOtrosPagos: { type: string, description: Obligatorio si no hay totalPercepciones. }
        receptor: { $ref: '#/components/schemas/NominaReceptor' }
        percepciones:
          type: object
          properties:
            totalGravado: { type: string }
            totalExento: { type: string }
            percepciones:
              type: array
              items: { $ref: '#/components/schemas/NominaPercepcion' }

    NominaRequest:
      type: object
      required: [receptorRfc, nomina]
      properties:
        receptorRfc: { type: string, description: RFC del empleado, ya dado de alta en "Mis clientes". }
        serie: { type: string, description: Código de una serie tipo nomina. Si se omite, se usa la favorita. }
        fecha: { type: string, format: date-time }
        notaPdf: { type: string, maxLength: 2000 }
        nomina: { $ref: '#/components/schemas/NominaDatos' }
        idempotency_key: { type: string, maxLength: 255 }

    ImpuestoRetenido:
      type: object
      required: [impuesto, montoRet, tipoPagoRet]
      properties:
        impuesto: { type: string, enum: ["001", "002", "003"], description: "Catálogo c_Impuesto: 001 ISR, 002 IVA, 003 IEPS." }
        baseRet: { type: number, minimum: 0 }
        montoRet: { type: number, minimum: 0 }
        tipoPagoRet: { type: string, enum: ["01", "02", "03", "04"], description: "Catálogo c_TipoPagoRet: 01 provisional, 02 definitivo." }

    RetencionRequest:
      type: object
      required: [tipoOperacion, fechaOperacion, montoOperacion]
      properties:
        tipoOperacion: { type: string, enum: [dividendos, enajenacion_acciones, pagos_extranjero], description: Únicos 3 tipos soportados en la API v1 (de 12 en el catálogo completo). }
        serie: { type: string, description: Código de una serie tipo retenciones. Si se omite, se usa la favorita. }
        rfcContraparte: { type: string, maxLength: 13 }
        nombreContraparte: { type: string, maxLength: 255, description: 'Debe ser exactamente "PÚBLICO EN GENERAL" si rfcContraparte es XAXX010101000.' }
        codigoPostalContraparte: { type: string, description: Requerido si la contraparte es nacional. }
        numRegIdTribContraparte: { type: string, description: Requerido si la contraparte es extranjera (en vez de codigoPostalContraparte). }
        fechaOperacion: { type: string, format: date }
        montoOperacion: { type: number, exclusiveMinimum: 0 }
        cveRetenc: { type: string, pattern: '^\d{2}$', description: Catálogo SAT c_CveRetenc (2 dígitos). }
        mesPeriodo: { type: integer, minimum: 1, maximum: 12 }
        mesFinPeriodo: { type: integer, minimum: 1, maximum: 12 }
        ejercicio: { type: integer, minimum: 2019 }
        impRetenidos:
          type: array
          items: { $ref: '#/components/schemas/ImpuestoRetenido' }
        idempotency_key: { type: string, maxLength: 255 }

    ErrorResponse:
      type: object
      properties:
        ok: { type: boolean, example: false }
        error: { type: string, example: "API key invalida." }
        codigo: { type: string, nullable: true, description: Código de error del PAC, solo en fallos de timbrado (502). }
        detalle: { type: string, nullable: true }
      examples:
        campoInvalido:
          summary: 400 - dato inválido (siempre trae el prefijo de la regla exacta)
          value: { ok: false, error: "[FC-219] Debe existir TotalPercepciones o TotalOtrosPagos (o ambos)." }
        rechazoPac:
          summary: 502 - el PAC rechazó el CFDI (codigo/detalle son la razón real del PAC)
          value: { ok: false, error: "El PAC rechazo el CFDI", codigo: "CFDI40145", detalle: "El atributo FormaPago no cumple con la condicion establecida." }
