openapi: 3.1.0
info:
  title: API de Factúralo y Punto
  version: "1.1"
  summary: Factura CFDI 4.0 desde tu sistema.
  description: |
    Timbra facturas, complementos de pago, traslados y retenciones; cancela; descarga el XML, el PDF y el acuse; crea
    tickets de autofacturación y recibe avisos firmados. Cada llave es de UNA empresa (un emisor) y se crea en
    Integraciones → «Para programadores», con sus permisos.

    **Reglas comunes**
    - Autenticación: `Authorization: Bearer tk_…` (la llave de la empresa).
    - Importes: texto decimal (`"1160.00"`), sin comas, signo ni notación exponencial; también se aceptan números,
      pero el texto evita redondeos. Lo que no se entiende se rechaza con el campo (422): nunca se cambia por otro valor.
    - Idempotencia: todo lo que timbra pide `Idempotency-Key` (8 a 120 caracteres). Ver **Idempotencia** abajo.
    - Límite: 120 peticiones por minuto por llave. Al pasarse responde 429 con `Retry-After` (segundos). Cada respuesta
      trae `X-RateLimit-Limit` y `X-RateLimit-Remaining`.
    - Errores: `{"error": {"codigo", "mensaje", "problemas": [{campo, mensaje, codigo?}]}}`. El `mensaje` ya viene en
      español para enseñárselo tal cual al usuario; `problemas` dice qué dato corregir, con el nombre del campo tal
      como lo mandas (`cliente.codigoPostal`, `conceptos[0].precio`). Ver **Errores** abajo.
    - Cada llamada con una llave válida queda en la bitácora de Integraciones → «Para programadores» durante 60 días (sin la llave ni el cuerpo).
    - Pruebas: con el servidor en modo simulado se timbra sin llegar al SAT (los folios de la cuenta de pruebas sí se descuentan, como en producción). El modo simulado revisa
      lo mismo que el real (dígito verificador del RFC, código postal, claves del SAT) para que lo que pasa en pruebas
      sea lo que pasará en producción.

    **Idempotencia**
    - La `Idempotency-Key` es el id de UNA operación en tu sistema (el id del pedido, del cobro de Stripe, del depósito).
      Vale por llave de API y por recurso: dos sistemas conectados a la misma empresa pueden usar el mismo id sin pisarse,
      y el mismo id sirve para la factura de un pedido (POST /facturas) y para la Carta Porte de su envío (POST /traslados).
    - La misma llave con los mismos datos devuelve lo mismo (200) y nunca timbra, cobra, abona ni avisa dos veces.
    - La misma llave con OTROS datos responde 409 `IDEMPOTENCIA` en /facturas (otro cliente, RFC, moneda, método de
      pago, conceptos, cantidades, precios o descuentos), /pagos (otro monto u otra fecha) y /tickets (otro total).
      En /traslados y /retenciones devuelve lo que ya existe: usa una llave por operación.
    - Si la primera vez se rechazó por datos (422) o por el SAT, la misma llave con los datos corregidos sí timbra.
    - Mientras otra petición con la misma llave sigue timbrando: 202 con `aviso.codigo: EN_PROCESO` y `Retry-After`.
      Repite con la MISMA llave.
    - Si no se pudo confirmar con el SAT (503 `TIMBRADO_SIN_CONFIRMAR`, `RED_TIEMPO_AGOTADO`…): repite con la MISMA
      llave en unos minutos; antes de reenviar se consulta si ya existe. **Nunca reintentes con otra llave**: sería
      otro comprobante.
    - `POST /facturas/{id}/reenviar` acepta una `Idempotency-Key` opcional: con ella, repetir no manda otro correo.
    - `POST /facturas/{id}/cancelar` no la pide: es idempotente por factura (pedirla otra vez devuelve cómo va, con
      `repetido: true`, sin volver al SAT ni gastar otro folio ni mandar otro aviso).

    **Errores** (el `codigo` es estable; el estado HTTP dice qué hacer)

    | HTTP | codigo | Cuándo | Qué hacer |
    |---|---|---|---|
    | 400 | SIN_IDEMPOTENCIA | Falta la Idempotency-Key o no mide de 8 a 120 | Error de programación: usa el id de tu operación |
    | 400 | JSON | El cuerpo no es JSON (o no es un objeto) | Error de programación |
    | 400 | MULTIPART | /constancia no vino como multipart/form-data con «archivo» | Error de programación |
    | 400 | CODIGO_INVALIDO, CODIGO_USADO, CODIGO_VENCIDO | /socios/canjear: el código no existe (o es de otra plataforma), ya se cambió por una llave o pasaron sus 60 segundos | Pide al negocio que vuelva a conectar |
    | 400 | WEBHOOK_URL | /socios/canjear: «webhookUrl» no es una dirección https pública (en desarrollo también se acepta http://localhost) | Corrígela y repite con el mismo código (no se gastó) |
    | 401 | SIN_LLAVE | No vino `Authorization: Bearer tk_…` (o no tiene esa forma) | Revisa el encabezado |
    | 401 | LLAVE_INVALIDA | La llave no existe | Revisa que esté completa; si funcionaba, reconecta |
    | 401 | LLAVE_REVOCADA | El negocio revocó la llave (o se desconectó) | Pide que vuelva a conectar |
    | 401 | LLAVE_VENCIDA | La llave venció | Pide una llave nueva |
    | 401 | SIN_SECRETO_SOCIO, SOCIO_INVALIDO | Plataformas conectadas: no vino el secreto de la plataforma, o no es de ninguna registrada | Revisa `Authorization: Bearer sk_…` (canjear) o `X-Socio-Secreto` |
    | 403 | SIN_PERMISO | La llave no tiene el permiso de esa ruta; o es de una conexión con un clic y quien la conectó ya no es dueño ni administrador de la empresa (la conexión queda en pausa) | El dueño crea una llave con ese permiso, o vuelve a conectar con un administrador |
    | 403 | FUERA_DE_PLAN | El plan no incluye la API o el módulo (retenciones, autofacturación, Carta Porte) | Enseña el mensaje: se cambia en Plan y folios |
    | 403 | CUENTA_SUSPENDIDA | La cuenta está suspendida (en todas las rutas que timbran o cancelan) | Enseña el mensaje |
    | 403 | EMPRESA_INACTIVA, EMISOR_NO_CORRESPONDE | La empresa se dio de baja o el emisor no es el de la llave | Enseña el mensaje |
    | 403 | SOCIO_INACTIVO | La plataforma está desactivada: no conecta ni abre sesiones (sí puede desconectar) | Escribe a soporte |
    | 403 | SOCIO_NO_VERIFICADO | /sesiones: la plataforma se dio de alta sola y Factúralo y Punto todavía no la verifica | Escríbenos para verificarla |
    | 403 | CONEXION_DE_OTRO_SOCIO | /sesiones o /socios/desconectar con una llave que no es de una conexión activa de tu plataforma | Usa la llave que te dio /socios/canjear |
    | 404 | NO_EXISTE | No existe en esta empresa (o es de otra: se responde igual); también un id que no es UUID | Revisa el id |
    | 404 | SIN_ARCHIVO | La factura no tiene ese archivo (sin timbrar, PDF que no se generó, sin acuse) | Enseña el mensaje |
    | 404 | RUTA | Ruta que no existe | Revisa la URL |
    | 405 | METODO | PUT, PATCH o DELETE | Usa GET o POST |
    | 409 | IDEMPOTENCIA | La misma Idempotency-Key con otros datos | Otra venta: otra llave. La misma: los mismos datos |
    | 409 | ESTADO_NO_PERMITE | El estado no lo permite (cancelar un borrador, un complemento que se está timbrando) | Enseña el mensaje |
    | 409 | CANCELACION_CON_RELACIONADOS | Tiene complementos de pago o notas vigentes; vienen en `error.relacionados` con su id | Cancela esos primero |
    | 409 | TICKETS_NO_DISPONIBLES | Otro proceso tomó los tickets de la global | Repite en unos segundos |
    | 409 | SIN_TIMBRAR | /reenviar de un comprobante sin timbrar | — |
    | 422 | DATOS_INVALIDOS | Datos por corregir (tipos, valores, reglas del Anexo 20, complementos) | Enseña `mensaje` y `problemas`; corrige y repite |
    | 422 | CLIENTE_INVALIDO | Datos del cliente en línea (RFC y su dígito verificador, nombre, régimen, código postal, uso, correo, país) | Corrige según su Constancia |
    | 422 | SIN_CLIENTE | No vino ni `clienteId` ni `cliente` | Manda uno |
    | 422 | CLIENTE_NO_ENCONTRADO | El `clienteId` no existe en esta empresa | Revisa el id (GET /clientes) |
    | 422 | PRODUCTO_NO_ENCONTRADO | Un `productoId` no existe (o se dio de baja) | Revisa `conceptos[i].productoId` |
    | 422 | SIN_CONCEPTOS | Falta `conceptos` o está vacío | — |
    | 422 | PAC_RECHAZO | El SAT o el servicio de timbrado rechazó un dato (traducido, con «(SAT CFDI40xxx)») | Corrige y repite (puede ser con la misma llave) |
    | 422 | PAC_CSD | El servicio de timbrado rechazó el sello digital | El dueño revisa su sello |
    | 422 | CSD_NO_DISPONIBLE | Falta el sello digital, venció o lo revocó el SAT | El dueño lo sube; repite con la misma llave |
    | 422 | FOLIOS_INSUFICIENTES | La cuenta no tiene folios | Compra folios (GET /cuenta → comprarFolios); repite con la misma llave |
    | 422 | SERIE_NO_ENCONTRADA | No hay una serie activa | El dueño la crea en Configuración |
    | 422 | NO_TIMBRADO | Otro rechazo del timbrado, ya escrito para el usuario | Enseña el mensaje |
    | 422 | MOTIVO | Motivo de cancelación que no existe | 01, 02, 03 o 04 |
    | 422 | CANCELACION_INVALIDA | 01 sin sustituta (o que no la relaciona con 04), 04 en una factura que no es global, folio de otro emisor | Corrige (`problemas`) |
    | 422 | CANCELACION_FUERA_DE_PLAZO | Pasó la fecha límite del SAT para cancelar | Haz una nota de crédito en la app |
    | 422 | CANCELACION_NO_APLICADA | El SAT o el servicio no aplicaron la cancelación: `error.solicitud`, `estado` sigue vigente | Enseña el mensaje |
    | 422 | NO_CANCELADA | Una retención que no se pudo cancelar | Enseña el mensaje |
    | 422 | PAGO_DESCARTADO | Ese pago se marcó en la app como «No es un cobro» | — |
    | 422 | PERIODICIDAD, PERIODO, GLOBAL | La global con periodicidad, año o mes que no existen, u otro error de la global | Corrige |
    | 422 | SIN_TIPO_CAMBIO | No se pudo consultar el tipo de cambio oficial (traslado de exportación) | Manda `exportacion.tipoCambioUsd` |
    | 422 | TICKET, TOTAL, IVA | Un ticket con total, forma de pago, código o IVA que no sirven | Corrige (`problemas`) |
    | 422 | CORREO, ADDENDA, CONSTANCIA, BUSQUEDA | El correo, la addenda, la constancia o la búsqueda no sirven | Enseña el mensaje |
    | 429 | DEMASIADAS_PETICIONES | Más de 120 por minuto con la llave (o muchos intentos sin llave válida) | Espera `Retry-After`; no se hizo nada |
    | 500 | ERROR_INTERNO | Una falla nuestra (el mensaje es general) | Repite con la misma llave; si sigue, soporte |
    | 501 | NO_DISPONIBLE | La ruta existe pero todavía no está disponible | Usa la alternativa que dice el mensaje |
    | 502 | CORREO_NO_SALIO | El correo de /reenviar no salió | Repite en unos minutos; no se cobró nada |
    | 503 | TIMBRADO_SIN_CONFIRMAR, RED_TIEMPO_AGOTADO, RED_SIN_CONEXION | El servicio de timbrado no respondió o no se pudo confirmar si timbró | Repite con la MISMA llave tras `Retry-After` (no se duplica) |
    | 503 | PAC_ERROR, PAC_AUTENTICACION, PAC_FOLIOS_INSUFICIENTES | Problema de la plataforma con el servicio de timbrado | Repite con la MISMA llave más tarde |

    Los 503 y el 500 traen `reintentable: true` y `Retry-After`. Un 202 no es un error: con `aviso.codigo`
    `EN_PROCESO` otra petición con la misma llave la está timbrando; con `ARCHIVO_NO_GUARDADO` la factura YA se timbró
    (trae `id` y `uuid`) y falta guardar su XML. En los dos casos repite con la MISMA Idempotency-Key.

    **Para quien integra**: 400 es un error de tu código; 401, que hay que volver a conectar; 403, algo que arregla el
    dueño en Factúralo y Punto (enseña el `mensaje`); 404, que no existe; 409, que el estado no lo permite; 422, datos
    por corregir (enseña `mensaje` y `problemas`); 429 y 503, espera y repite con la misma llave; 500, repite y avisa a
    soporte si sigue. Durante un tiempo, los datos por corregir de algunos errores (cliente en línea, IVA, retención,
    método de pago, pagos) traen además `campos` (un objeto `{campo: mensaje}`) por compatibilidad: lee `problemas`.
  contact:
    name: Soporte de Factúralo y Punto
    url: https://facturaloypunto.com
servers:
  - url: https://facturaloypunto.com/api/v1
    description: Producción
  - url: http://localhost:3000/api/v1
    description: Desarrollo local (modo simulado)
security:
  - llave: []
tags:
  - name: Facturas
  - name: Pagos
  - name: Traslados
  - name: Retenciones
  - name: Autofacturación
  - name: Clientes y catálogos
  - name: Cuenta
  - name: Plataformas conectadas
    description: |
      Para sistemas que facturan por sus clientes (un ERP, un punto de venta, una plataforma de marketing). El negocio
      conecta su cuenta desde tu sistema con un clic, sube su sello digital en Factúralo y Punto (tu sistema nunca lo toca)
      y recibes una llave de su empresa. Cada negocio le paga directo a Factúralo y Punto: la plataforma no cobra comisión.

      1. Manda a la persona a `GET {Factúralo y Punto}/conectar?socio=<tu clave>&estado=<aleatorio>&regreso=<URL registrada>&correo=<opcional>&negocio=<opcional>`
         (fuera de /api/v1). Ahí entra o crea su cuenta (con `correo` ya escrito; si ese correo ya tiene cuenta, se le
         pide su contraseña: un correo es una persona), elige o crea su empresa, sube su sello si no lo tiene (puede
         autorizar sin él: lo ves en `selloEstado` de GET /cuenta), ve qué permisos pides y autoriza. Sólo el dueño o
         un administrador de la empresa autoriza.
      2. Vuelve a `<regreso>?codigo=<un solo uso, 60 s>&estado=<el mismo>`; si cancela, a `<regreso>?error=cancelado&estado=…`.
         Revisa que `estado` sea el tuyo. Si `regreso` no es idéntica a una de tus URL registradas (o tu plataforma no
         existe o está desactivada), no se redirige: Factúralo y Punto enseña el error.
      3. Tu servidor cambia el código por la llave en `POST /socios/canjear` (con tu secreto en `Authorization`), y si
         quieres da de alta ahí la dirección de sus avisos.
      4. `POST /sesiones` te da una liga para que la persona abra Factúralo y Punto ya con sesión (sólo plataformas
         verificadas). `POST /socios/desconectar` revoca la llave, su aviso y las sesiones abiertas con tus ligas.

      **Cómo registrar tu plataforma.** En Factúralo y Punto, Integraciones → «Para programadores» → «Soy una plataforma»: clave (la de
      `?socio=`), nombre, URL de regreso (https; en desarrollo también http://localhost) y lo que pedirás. Te damos un
      secreto `sk_<clave>_…` una sola vez (sólo guardamos su hash; al cambiarlo, el anterior sirve 24 horas más). Así
      queda «sin verificar»: conecta negocios y usa sus llaves, pero no pide cancelar ni abre sesiones, y la pantalla de
      autorizar lo dice. Para verificarla, escríbenos: la damos de alta nosotros (variable SOCIOS o `scripts/socio.ts`).

      La conexión manual sigue sirviendo: el negocio crea la llave en Integraciones → «Para programadores» y el destino de avisos en
      la misma vista, en «Avisos a tu sistema».

paths:
  /facturas:
    get:
      tags: [Facturas]
      summary: Las últimas 50 facturas de la empresa (`?estado=` filtra)
      description: Lista también los intentos que no se timbraron (borrador o error, con `uuid` null). Pide `comprobantes:leer`.
      parameters: [{ name: estado, in: query, schema: { $ref: "#/components/schemas/EstadoFactura" } }]
      responses:
        "200":
          description: Lista
          content:
            application/json:
              schema: { type: object, properties: { datos: { type: array, items: { $ref: "#/components/schemas/Factura" } } } }
        "401": { $ref: "#/components/responses/SinLlave" }
        "403": { $ref: "#/components/responses/SinPermiso" }
        "429": { $ref: "#/components/responses/Limite" }
    post:
      tags: [Facturas]
      summary: Timbrar una factura (ingreso)
      description: |
        Pide `comprobantes:emitir` y `Idempotency-Key`. El receptor va por `clienteId` (de tu catálogo, GET /clientes) o
        en línea (`cliente`). **Qué datos necesitas, por caso:**

        - **Persona moral o física de México**: `cliente.rfc` (con su dígito verificador correcto), `cliente.nombre`
          (como en su Constancia de Situación Fiscal: en mayúsculas y sin «S.A. de C.V.»), `cliente.codigoPostal`
          (el fiscal, que exista en el catálogo del SAT) y `cliente.regimenFiscal` (que corresponda al tipo de persona:
          601 para una moral, 612, 605, 626… para una física). `usoCfdi` es opcional: si no viene se usa el guardado del
          cliente (con `clienteId`) o G03 (S01 con RFC genérico). Si el cliente ya está en tu catálogo con ese RFC y
          nombre (sin importar acentos ni mayúsculas), se factura con lo que mandas y se actualiza su código postal,
          régimen y correo (la respuesta lo dice en `avisos`, con `CLIENTE_ACTUALIZADO`).
        - **Una persona sin RFC (público en general con su nombre)**: `cliente.rfc` XAXX010101000 y `cliente.nombre`; el
          régimen 616, el uso S01 y el código postal del lugar de expedición se ponen solos.
        - **PUBLICO EN GENERAL (tu factura global con tus propias ventas)**: además, `informacionGlobal`
          `{periodicidad: "01" a "05", meses: "01" a "12" (o "13" a "18" bimestral), anio}`. Si tus ventas son tickets
          de Factúralo y Punto, usa POST /facturas/global.
        - **Cliente del extranjero**: `cliente: {extranjero: true, nombre, pais (c_Pais, p. ej. USA), registroFiscal}`
          (9 dígitos para USA o CAN); el RFC XEXX010101000, el régimen 616, el uso S01 y el código postal del lugar de
          expedición se ponen solos.
        - **A crédito (PPD)**: `metodoPago: "PPD"`; la forma de pago es siempre 99 (por definir) aunque mandes otra, y
          después registras cada pago con POST /pagos.
        - **De contado (PUE, por omisión)**: manda siempre `formaPago` del catálogo del SAT como texto de dos dígitos
          ("01" efectivo, "03" transferencia, "04" tarjeta de crédito, "28" tarjeta de débito). Si falta, sale con "03"
          y la respuesta trae el aviso `FORMA_PAGO_POR_OMISION`.
        - **En dólares u otra moneda**: `moneda` (c_Moneda) y `tipoCambio` (pesos por unidad, hasta 6 decimales).
        - **Retenciones** (en cada concepto, `retencion`): `honorarios` y `arrendamiento` (ISR 10 % y dos terceras partes
          del IVA) y `resico` (ISR 1.25 %, emisor en el régimen 626) sólo las hace una persona MORAL cuando le paga a una
          persona FÍSICA: tu empresa debe ser persona física y tu cliente persona moral (si no, 422
          `RETENCION_NO_APLICA`). También `fletes` (IVA 4 %), `personal` (IVA 6 %) y `otra` con `retencionIsr` y/o
          `retencionIva` en porcentaje (uno de los dos puede ir en "0", no los dos).
        - **Factura que sustituye a otra** (para cancelar la anterior con el motivo 01): `relacionados:
          [{tipoRelacion: "04", uuids: [folio fiscal de la anterior]}]`. Un solo tipo de relación por factura.

        **Cada concepto**: `descripcion` y `precio` (unitario, sin impuestos, mayor que cero, hasta 6 decimales);
        `cantidad` (1 por omisión); `claveProdServ` (8 dígitos que existan en el catálogo del SAT; 01010101 por omisión)
        y `claveUnidad` (E48 por omisión; búscalas con GET /catalogos/…); `iva`: "16" (por omisión), "8", "0", "exento"
        o "no-objeto". Con `productoId` (de tu catálogo), lo que no mandes —descripción, precio, claves y, si no mandas
        `iva`, `retencion`, `ieps` ni `objetoImp`, sus impuestos— sale del producto. `descuento` es un IMPORTE en pesos
        (no un porcentaje), hasta 2 decimales y no mayor que el importe. `ieps`: `{tipo: "tasa", valor: 8}` (porcentaje
        del catálogo) o `{tipo: "cuota", valor: "0.6166"}` (pesos por unidad). `objetoImp` de "01" a "08" casi nunca hace
        falta: sale del IVA.

        `enviarCorreo`: por omisión sí se manda la factura al correo del cliente (con su PDF y su XML) si tiene correo;
        manda `false` si tu sistema envía sus propios correos.

        La respuesta 201 puede traer `avisos` (`[{campo, codigo, mensaje}]`): algo que conviene saber de una factura
        que sí salió: un valor por omisión (`FORMA_PAGO_POR_OMISION`), el cliente actualizado (`CLIENTE_ACTUALIZADO`) o
        un dato que no existe en esta ruta y se ignoró (`CAMPO_DESCONOCIDO`, p. ej. «relacionado» en vez de
        «relacionados»).
      parameters: [{ $ref: "#/components/parameters/Idempotencia" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FacturaNueva" }
            example:
              cliente: { rfc: EKU9003173C9, nombre: ESCUELA KEMPER URGATE, codigoPostal: "42501", regimenFiscal: "601", usoCfdi: G03, correo: pagos@ejemplo.test }
              conceptos: [{ descripcion: Plan mensual, cantidad: 1, precio: "850.00", claveProdServ: "81112100", claveUnidad: E48, iva: "16" }]
              metodoPago: PUE
              formaPago: "04"
              enviarCorreo: true
      responses:
        "201": { description: Timbrada, content: { application/json: { schema: { $ref: "#/components/schemas/FacturaTimbrada" } } } }
        "200": { description: "La misma Idempotency-Key con los mismos datos: devuelve la que ya existe", content: { application/json: { schema: { $ref: "#/components/schemas/Factura" } } } }
        "202": { $ref: "#/components/responses/EnProceso" }
        "400": { $ref: "#/components/responses/Peticion" }
        "401": { $ref: "#/components/responses/SinLlave" }
        "403": { $ref: "#/components/responses/SinPermiso" }
        "409": { $ref: "#/components/responses/Idempotencia" }
        "422": { $ref: "#/components/responses/Validacion" }
        "429": { $ref: "#/components/responses/Limite" }
        "500": { $ref: "#/components/responses/Interno" }
        "503": { $ref: "#/components/responses/Temporal" }
  /facturas/{id}:
    get:
      tags: [Facturas]
      summary: Una factura, con su receptor, lo que falta por cobrar y su cancelación
      description: "`saldo` es null si es de contado, está cancelada o espera su cancelación. `cancelacion` es null si nunca se pidió. Sus `conceptos` vienen con la misma forma de POST /facturas (para precargar la que la sustituye)."
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200": { description: La factura, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Factura" }, { type: object, properties: { tipo: { type: string }, receptor: { type: object }, metodoPago: { type: string }, formaPago: { type: string }, usoCfdi: { type: string }, moneda: { type: string }, subtotal: { type: string }, saldo: { type: [string, "null"] }, cancelacion: { oneOf: [{ $ref: "#/components/schemas/Cancelacion" }, { type: "null" }] }, conceptos: { type: array, items: { type: object } } } }] } } } }
        "404": { $ref: "#/components/responses/NoExiste" }
  /facturas/{id}/xml:
    get:
      tags: [Facturas]
      summary: El XML timbrado (con su addenda si la tiene; `?original=1` sin ella)
      parameters: [{ $ref: "#/components/parameters/Id" }, { name: original, in: query, schema: { type: string } }]
      responses: { "200": { description: XML, content: { application/xml: {} } }, "404": { $ref: "#/components/responses/NoExiste" } }
  /facturas/{id}/pdf:
    get:
      tags: [Facturas]
      summary: El PDF
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses: { "200": { description: PDF, content: { application/pdf: {} } }, "404": { $ref: "#/components/responses/NoExiste" } }
  /facturas/{id}/acuse:
    get:
      tags: [Facturas]
      summary: El acuse de cancelación del SAT (XML; o el PDF que se registró a mano)
      description: Se guarda cuando el SAT la da por cancelada en la misma petición. El de una cancelación que se resolvió después (esperaba a tu cliente) todavía no se baja del servicio de timbrado; responde 404 SIN_ARCHIVO.
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses: { "200": { description: Acuse, content: { application/xml: {}, application/pdf: {} } }, "404": { $ref: "#/components/responses/NoExiste" } }
  /facturas/{id}/cancelar:
    post:
      tags: [Facturas]
      summary: Cancelar ante el SAT
      description: |
        Pide `comprobantes:cancelar` (sólo la puede dar quien puede cancelar en la empresa; si a su autor le quitan ese
        permiso, la llave deja de cancelar). No pide Idempotency-Key: es idempotente por factura.

        **Motivos del SAT**
        - `02` Comprobante emitido con errores sin relación: el más común.
        - `03` No se llevó a cabo la operación.
        - `01` Con errores con relación: va con `uuidSustituto`, el folio fiscal de una factura VIGENTE de tu misma
          empresa que la relacione con tipo 04 (hazla antes con POST /facturas y `relacionados`), o de una hecha en otro
          sistema. Un folio de otro emisor de la plataforma se rechaza.
        - `04` Operación nominativa relacionada en una factura global: SÓLO para cancelar la factura global de público
          en general cuando una de sus ventas se facturó después a nombre del cliente. En otra factura responde 422.

        **Qué pasa**
        - Se cancela al momento (200 `cancelado`) si es de hasta $1,000, de público en general, extranjero, RIF,
          egreso, traslado, nómina o complemento de pago, o si se pide dentro de las 24 horas del timbrado.
        - Si no, tu cliente tiene 3 días hábiles para aceptarla en su buzón tributario (200 `cancelacion_pendiente`,
          `requiereAceptacion: true`, `cancelacion.expiraEn`); si no responde, se da por aceptada. Se revisa sola cada
          15 minutos y, cuando queda cancelada, llega el aviso `factura.cancelada`; si la rechaza,
          `factura.cancelacion_rechazada`.
        - Si el servicio de timbrado no contestó a tiempo: 202 con `estado: timbrado` y `cancelacion.estado:
          solicitada`; se revisa sola. No la pidas otra vez (si lo haces, recibes cómo va).
        - Si el SAT no la aplica: 422 `CANCELACION_NO_APLICADA`; la factura sigue vigente.
        - Cada solicitud que llega al SAT gasta un folio (pedirla otra vez no).
        - Antes hay que cancelar sus complementos de pago y notas de crédito vigentes (409
          `CANCELACION_CON_RELACIONADOS`, con sus ids en `error.relacionados`); al cancelar un complemento, el saldo
          vuelve a la factura.
        - Pasado el plazo del SAT (422 `CANCELACION_FUERA_DE_PLAZO`) se corrige con una nota de crédito, que hoy se hace
          en la app.
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [motivo], properties: { motivo: { enum: ["01", "02", "03", "04"] }, uuidSustituto: { type: string, description: "Sólo con el motivo 01" } } } } }
      responses:
        "200": { description: "Cancelada (`cancelado`) o en espera de que tu cliente acepte (`cancelacion_pendiente`). Con `repetido: true` si ya se había pedido.", content: { application/json: { schema: { $ref: "#/components/schemas/RespuestaCancelacion" } } } }
        "202": { description: "Se mandó y falta la respuesta (o la pidió otra petición que sigue en curso): sigue `timbrado`, `cancelacion.estado: solicitada`", content: { application/json: { schema: { $ref: "#/components/schemas/RespuestaCancelacion" } } } }
        "400": { $ref: "#/components/responses/Peticion" }
        "401": { $ref: "#/components/responses/SinLlave" }
        "403": { $ref: "#/components/responses/SinPermiso" }
        "404": { $ref: "#/components/responses/NoExiste" }
        "409": { description: "ESTADO_NO_PERMITE (no está timbrada) o CANCELACION_CON_RELACIONADOS (con `error.relacionados: [{id, tipo, serie, folio}]`)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: "MOTIVO, CANCELACION_INVALIDA, CANCELACION_FUERA_DE_PLAZO o CANCELACION_NO_APLICADA (con `error.solicitud`, `id` y `estado`, que sigue vigente)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/Limite" }
        "500": { $ref: "#/components/responses/Interno" }
  /facturas/{id}/reenviar:
    post:
      tags: [Facturas]
      summary: Mandar otra vez por correo una factura timbrada (con su PDF y su XML)
      description: "Pide `comprobantes:emitir`. Con `Idempotency-Key` (opcional), repetir la petición no manda otro correo (200 con `repetido: true`)."
      parameters: [{ $ref: "#/components/parameters/Id" }, { name: Idempotency-Key, in: header, required: false, schema: { type: string, minLength: 8, maxLength: 120 } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [correo], properties: { correo: { oneOf: [{ type: string }, { type: array, items: { type: string } }], description: "Uno o varios (hasta 10), separados por coma" }, mensaje: { type: string } } } } }
      responses:
        "200": { description: Enviado, content: { application/json: { schema: { type: object, properties: { id: { type: string }, enviado: { type: array, items: { type: string } }, simulado: { type: boolean }, repetido: { type: boolean } } } } } }
        "404": { $ref: "#/components/responses/NoExiste" }
        "409": { description: "SIN_TIMBRAR: la factura no está timbrada" }
        "422": { description: "CORREO: falta, no es válido o son más de 10", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: "CORREO_NO_SALIO: repite en unos minutos; no se cobró nada" }
  /facturas/{id}/addenda:
    post:
      tags: [Facturas]
      summary: Agregar la addenda que pide una cadena (a una copia del XML; el timbrado no se toca)
      description: "Pide `comprobantes:emitir`. `addenda` con el XML o texto (con variables `{{folio}}`, `{{total}}`…); sin ella se usa la plantilla guardada del cliente; `addenda: null` o `quitar: true` la quita."
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { addenda: { type: [string, "null"] }, valores: { type: object }, guardarComoPlantilla: { type: boolean }, quitar: { type: boolean } } } } } }
      responses: { "200": { description: Hecho }, "404": { $ref: "#/components/responses/NoExiste" }, "422": { description: "ADDENDA: no hay plantilla, el XML está mal formado (dice el renglón y qué corregir) o no hay addenda que quitar" } }
  /facturas/global:
    post:
      tags: [Facturas, Autofacturación]
      summary: Factura global de público en general con los tickets sin facturar del periodo
      description: |
        Pide `comprobantes:emitir`, el módulo de autofacturación e `Idempotency-Key`. Junta los tickets (POST /tickets)
        que nadie facturó en el periodo y timbra una factura a XAXX010101000 con su InformacionGlobal. Sin cuerpo es
        mensual (04) y del mes anterior; un cuerpo que no es JSON responde 400. Si no hay ventas pendientes responde 200
        con `id: null`. Un ticket con datos que no sirven no detiene la global del periodo. La misma llave otra vez
        devuelve la que ya salió (200, `repetida: true`); si no se había timbrado (sello, folios, corte de red), la vuelve
        a intentar con lo que ya tiene apartado.
      parameters: [{ $ref: "#/components/parameters/Idempotencia" }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                periodicidad: { enum: ["01", "02", "03", "04", "05"], description: "01 diaria, 02 semanal, 03 quincenal, 04 mensual, 05 bimestral" }
                anio: { type: integer }
                mes: { type: integer, minimum: 1, maximum: 12 }
                desde: { type: string, format: date, description: "Diaria, semanal o quincenal: primer día" }
                hasta: { type: string, format: date, description: "Día siguiente al último" }
                serie: { type: string }
      responses:
        "201": { description: Timbrada, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Factura" }, { type: object, properties: { tickets: { type: array, items: { type: string } }, enPlazo: { type: integer }, desde: { type: string }, hasta: { type: string }, mensaje: { type: string }, repetida: { type: boolean } } }] } } } }
        "200": { description: Repetida, o sin ventas pendientes (`id` null) }
        "202": { $ref: "#/components/responses/EnProceso" }
        "400": { $ref: "#/components/responses/Peticion" }
        "403": { $ref: "#/components/responses/SinPermiso" }
        "422": { $ref: "#/components/responses/Validacion" }
        "503": { $ref: "#/components/responses/Temporal" }
  /pagos:
    post:
      tags: [Pagos]
      summary: Registrar un pago recibido y timbrar su complemento (REP)
      description: |
        Pide `pagos:escribir` e `Idempotency-Key` (identifica el pago: repetirla nunca timbra otro complemento; con otro
        monto u otra fecha responde 409). `fecha` (AAAA-MM-DD, no futura en hora de México) es obligatoria. Con
        `facturas` abona ésas (`id`, `uuid` o `folio`, y su `abono`); sin ellas, las elige por el folio en
        `descripcion` o por el `rfc`. Si no se puede saber de quién es, queda por asignar en la app (202). Importes con
        hasta 2 decimales, sin signo ni comas: lo que no se entiende se rechaza (nunca se le quita el signo). Si ese
        mismo depósito ya tiene su complemento (lo importaste del estado de cuenta: mismo día, monto y facturas), no se
        timbra otro: responde 200 con ése. Manda como `referencia` la clave de rastreo del banco.
      parameters: [{ $ref: "#/components/parameters/Idempotencia" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fecha]
              properties:
                fecha: { type: string, format: date }
                monto: { type: string }
                formaPago: { type: string, description: "c_FormaPago (03 transferencia por omisión; nunca 99)" }
                referencia: { type: string }
                rfc: { type: string }
                descripcion: { type: string }
                facturas: { type: array, items: { type: object, properties: { id: { type: string }, uuid: { type: string }, folio: { type: string }, abono: { type: string } } } }
                moneda: { type: string }
                tipoCambio: { type: string }
                enviarCorreo: { type: boolean, description: "Por omisión sí se manda el complemento al cliente" }
      responses:
        "201": { description: Complemento timbrado }
        "200": { description: La misma Idempotency-Key, o ese depósito ya tenía su complemento }
        "202": { description: "Quedó por asignar en la app (`pago.estado: por_asignar`), o se está timbrando con la misma llave (`aviso.codigo: EN_PROCESO`)" }
        "409": { $ref: "#/components/responses/Idempotencia" }
        "422": { $ref: "#/components/responses/Validacion" }
        "503": { $ref: "#/components/responses/Temporal" }
  /traslados:
    post:
      tags: [Traslados]
      summary: Traslado de mercancía propia (Carta Porte 3.1 o salida al extranjero con comercio exterior)
      description: Pide `comprobantes:emitir`, el módulo de Carta Porte e `Idempotency-Key` (su propio espacio de claves). Manda `cartaPorte` o `exportacion` (uno por llamada). La forma completa está en Integraciones → API.
      parameters: [{ $ref: "#/components/parameters/Idempotencia" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { cartaPorte: { type: object }, exportacion: { type: object }, observaciones: { type: string } } } } } }
      responses: { "201": { description: Timbrado }, "202": { $ref: "#/components/responses/EnProceso" }, "422": { $ref: "#/components/responses/Validacion" }, "503": { $ref: "#/components/responses/Temporal" } }
  /retenciones:
    get:
      tags: [Retenciones]
      summary: Las últimas 50 constancias de retenciones
      responses: { "200": { description: Lista } }
    post:
      tags: [Retenciones]
      summary: Timbrar una constancia de retenciones e información de pagos 2.0
      description: "Pide `comprobantes:emitir`, el módulo de retenciones e `Idempotency-Key`. `receptor` (con `codigoPostal`), `periodo` {mesInicial, mesFinal, ejercicio}, `impuestosRetenidos` y `complementos`; si falta `receptor` o `periodo` responde 422 con el campo. La forma completa está en Integraciones → API."
      parameters: [{ $ref: "#/components/parameters/Idempotencia" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses: { "201": { description: Timbrada }, "200": { description: La misma Idempotency-Key }, "202": { $ref: "#/components/responses/EnProceso" }, "422": { $ref: "#/components/responses/Validacion" }, "503": { $ref: "#/components/responses/Temporal" } }
  /retenciones/{id}:
    get: { tags: [Retenciones], summary: Una constancia, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: La constancia }, "404": { $ref: "#/components/responses/NoExiste" } } }
  /retenciones/{id}/{formato}:
    get:
      tags: [Retenciones]
      summary: Su XML o su PDF
      parameters: [{ $ref: "#/components/parameters/Id" }, { name: formato, in: path, required: true, schema: { enum: [xml, pdf] } }]
      responses: { "200": { description: Archivo }, "404": { $ref: "#/components/responses/NoExiste" } }
  /retenciones/{id}/cancelar:
    post:
      tags: [Retenciones]
      summary: Cancelar una constancia (motivos 01, 02 o 03; no usa folio)
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { motivo: { enum: ["01", "02", "03"] }, uuidSustituto: { type: string } } } } } }
      responses: { "200": { description: Cancelada }, "404": { $ref: "#/components/responses/NoExiste" }, "422": { description: "MOTIVO o NO_CANCELADA" } }
  /tickets/{codigo}:
    get:
      tags: [Autofacturación]
      summary: Un ticket por su código (estatus, liga del portal y QR)
      description: Pide `tickets:leer` o `tickets:escribir`.
      parameters: [{ name: codigo, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: El ticket, content: { application/json: { schema: { type: object, properties: { id: { type: string }, codigo: { type: string }, total: { type: string }, fecha: { type: string }, estado: { enum: [disponible, reservado, facturado, en_global, vencido] }, facturableHasta: { type: [string, "null"] }, comprobanteId: { type: [string, "null"] }, referencia: { type: [string, "null"] }, portal: { type: [string, "null"] }, portalUrl: { type: [string, "null"] }, qrSvg: { type: [string, "null"] } } } } } }
        "404": { $ref: "#/components/responses/NoExiste" }
  /tickets:
    post:
      tags: [Autofacturación]
      summary: Crear un ticket de venta para que el cliente lo facture en el portal
      description: |
        Pide `tickets:escribir` y el módulo de autofacturación. Devuelve el código del ticket, la liga del portal con el
        código y el total ya puestos (`portalUrl`) y su QR en SVG (`qrSvg`) para imprimirlo. Lo que nadie facture entra
        a la global del periodo (POST /facturas/global). `total` es lo cobrado con impuestos, con hasta 2 decimales y sin
        comas (si no se entiende, 422: nunca se redondea); `formaPago` debe estar en el catálogo del SAT.
        Con `Idempotency-Key` (recomendada) la misma operación devuelve el mismo ticket (200, `repetido: true`): sin
        `codigo` propio, el código sale de la llave («T-7K3QH-9MXR2»); con `codigo`, el mismo código y total son la misma
        venta. La misma llave con otro total responde 409. Recomendado: manda tu propio `codigo` (el folio de tu venta).
      parameters: [{ name: Idempotency-Key, in: header, required: false, schema: { type: string, minLength: 8, maxLength: 120 } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [total], properties: { total: { type: string, description: "Lo cobrado con impuestos" }, descripcion: { type: string }, formaPago: { type: string }, codigo: { type: string, description: "4 a 40 letras o números" }, referencia: { type: string }, iva: { enum: ["16", "8", "0", exento] }, productoId: { type: string } } } } }
      responses:
        "201":
          description: Creado
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, codigo: { type: string }, total: { type: string }, facturableHasta: { type: string }, portal: { type: [string, "null"] }, portalUrl: { type: [string, "null"] }, qrSvg: { type: [string, "null"] } } }
        "200": { description: "La misma operación otra vez: el mismo ticket, con repetido: true" }
        "409": { description: La misma Idempotency-Key con otro total }
        "422": { description: "TOTAL, TICKET (total, forma de pago, código o producto) o IVA, con `problemas`" }
  /clientes:
    get:
      tags: [Clientes y catálogos]
      summary: Los clientes de la empresa (hasta 200; `?q=` busca por nombre o RFC)
      description: Pide `clientes:leer`.
      parameters: [{ name: q, in: query, schema: { type: string } }]
      responses: { "200": { description: Lista } }
  /catalogos/productos-servicios:
    get:
      tags: [Clientes y catálogos]
      summary: Buscar en c_ClaveProdServ del SAT (por clave o palabras, sin importar acentos)
      parameters: [{ name: q, in: query, required: true, schema: { type: string, minLength: 2 } }]
      responses:
        "200": { description: Hasta 20, content: { application/json: { schema: { type: object, properties: { datos: { type: array, items: { type: object, properties: { clave: { type: string }, descripcion: { type: string } } } } } } } } }
  /catalogos/unidades:
    get:
      tags: [Clientes y catálogos]
      summary: Buscar en c_ClaveUnidad del SAT
      parameters: [{ name: q, in: query, required: true, schema: { type: string, minLength: 2 } }]
      responses:
        "200": { description: Hasta 20, content: { application/json: { schema: { type: object, properties: { datos: { type: array, items: { type: object, properties: { clave: { type: string }, nombre: { type: string } } } } } } } } }
  /constancia:
    post:
      tags: [Clientes y catálogos]
      summary: Leer la Constancia de Situación Fiscal (PDF o foto de su código QR)
      description: Pide `comprobantes:emitir`. Con el PDF se lee su texto; con una foto se lee el QR y se consulta la cédula en el SAT.
      requestBody:
        required: true
        content: { multipart/form-data: { schema: { type: object, required: [archivo], properties: { archivo: { type: string, format: binary } } } } }
      responses:
        "200": { description: Datos fiscales, content: { application/json: { schema: { type: object, properties: { rfc: { type: string }, nombre: { type: string }, codigoPostal: { type: string }, regimenFiscal: { type: [string, "null"] }, regimenes: { type: array, items: { type: string } }, tipoPersona: { enum: [fisica, moral] }, estatus: { type: [string, "null"] }, avisos: { type: array, items: { type: string } } } } } } }
        "400": { $ref: "#/components/responses/Peticion" }
        "422": { description: "CONSTANCIA: no hay archivo, no se encontró el QR, la foto pesa más de 8 MB o no es una constancia" }
  /cuenta:
    get:
      tags: [Cuenta]
      summary: Folios, plan y sello de la empresa de la llave
      description: Pide `comprobantes:leer`.
      responses:
        "200":
          description: Estado
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { type: [string, "null"] }
                  estadoCuenta: { type: [string, "null"] }
                  planVenceEn: { type: [string, "null"], format: date-time }
                  foliosRestantes: { type: integer }
                  vigenciaFolios: { type: array, items: { type: object, properties: { folios: { type: integer }, venceEn: { type: string, format: date-time }, origen: { enum: [plan, paquete] } } } }
                  foliosSinVencimiento: { type: integer }
                  selloVence: { type: [string, "null"], format: date-time }
                  selloEstado: { type: [string, "null"] }
                  comprarFolios: { type: string, description: "Liga a Plan y folios en Factúralo y Punto" }
                  empresa: { type: object, properties: { id: { type: string }, rfc: { type: string }, nombre: { type: string }, portalUrl: { type: [string, "null"] } } }
  /socios/canjear:
    post:
      tags: [Plataformas conectadas]
      summary: Cambiar el código de la conexión por la llave de la empresa del negocio (servidor a servidor)
      description: |
        Con el `codigo` que llegó a tu URL de regreso (sirve una sola vez y vence a los 60 segundos de que la persona
        autorizó). La llave lleva los permisos que la persona autorizó (cancelar, sólo si su usuario puede cancelar y tu
        plataforma está verificada) y sólo se muestra en esta respuesta: guárdala cifrada. Si la empresa ya estaba
        conectada con tu plataforma, la conexión anterior se reemplaza (su llave y su aviso dejan de servir). La llave
        vive mientras tu plataforma esté activa y quien conectó siga siendo dueño o administrador de la empresa; si deja
        de serlo, la llave responde 403 SIN_PERMISO hasta que un administrador vuelva a conectar.

        Con `webhookUrl` se da de alta el aviso de esa empresa con todos los eventos (también los que se agreguen
        después), con su cadena de consulta tal cual (`?negocio=<uuid>`, `?propia=1`); `webhook.secreto` sirve para
        verificar la firma y tampoco se vuelve a mostrar. Debe ser https pública; en desarrollo (nunca en producción)
        también se acepta `http://localhost` o `http://127.0.0.1`. Si no sirve, responde 400 `WEBHOOK_URL` sin gastar el
        código. Límite: 60 canjes por minuto por plataforma y por dirección.
      security: [{ socioBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [codigo]
              properties:
                codigo: { type: string, description: "El que llegó a tu URL de regreso, tal cual" }
                webhookUrl: { type: string, format: uri, description: "A dónde mandar los avisos de esta empresa (se respeta la cadena de consulta)" }
      responses:
        "200":
          description: Conectada
          content:
            application/json:
              schema:
                type: object
                properties:
                  empresaId: { type: string }
                  rfc: { type: string }
                  nombre: { type: string }
                  portalUrl: { type: [string, "null"], description: "Su portal de autofacturación, si lo tiene activo" }
                  llave: { type: string, description: "tk_… (sólo se muestra esta vez)" }
                  webhook: { type: [object, "null"], description: "null si no mandaste webhookUrl", properties: { url: { type: string }, secreto: { type: string, description: "whsec_… (sólo se muestra esta vez)" } } }
        "400": { description: "CODIGO_INVALIDO (no existe o es de otra plataforma), CODIGO_USADO, CODIGO_VENCIDO, WEBHOOK_URL o JSON", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: "SIN_SECRETO_SOCIO o SOCIO_INVALIDO", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: "SOCIO_INACTIVO; EMPRESA_INACTIVA (la empresa se dio de baja) o SIN_PERMISO (quien autorizó ya no la administra)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/Limite" }
  /socios/desconectar:
    post:
      tags: [Plataformas conectadas]
      summary: Desconectar el negocio (revoca la llave con que se llama y su aviso)
      description: |
        Con la llave del negocio y tu secreto en `X-Socio-Secreto`: la llave queda revocada al momento (las siguientes
        peticiones con ella responden 401 LLAVE_REVOCADA), el aviso que diste de alta al canjear se borra y se cierran las
        sesiones que abriste con tus ligas para esa persona. Llamarla otra vez responde 200 con `repetido: true`. Una
        llave que el negocio creó a mano en Integraciones → API se revoca sólo con ella (el aviso, si lo hay, lo borra el
        dueño en Integraciones → Avisos). Una plataforma desactivada sí puede desconectar.
      security: [{ llave: [], socio: [] }, { llave: [] }]
      responses:
        "200": { description: "Desconectada: `{desconectada: true, mensaje?}` (o `{desconectada: true, repetido: true}`)" }
        "401": { description: "SIN_LLAVE o LLAVE_INVALIDA; SIN_SECRETO_SOCIO o SOCIO_INVALIDO (la llave es de una conexión con un clic)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: "CONEXION_DE_OTRO_SOCIO: la llave es de una conexión con otra plataforma", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /sesiones:
    post:
      tags: [Plataformas conectadas]
      summary: Liga para entrar a Factúralo y Punto ya con sesión (un solo uso, 60 s; sólo plataformas verificadas)
      description: |
        Con la llave de la conexión y tu secreto en `X-Socio-Secreto`. La liga es del mismo origen que la API, sirve una
        vez y vence a los 60 segundos; entra como la persona que conectó, a la pantalla de `destino` de esa empresa
        (`configuracion` abre el sello digital). Ábrela en el navegador de la persona (redirígela o ábrela en una
        pestaña); no la sigas desde tu servidor.

        Al abrirla:
        - Si en ese navegador ya está la sesión de esa persona, entra directo.
        - Si no (no hay sesión, o es la de otra persona), Factúralo y Punto le enseña a quién y a qué empresa va a entrar y
          la persona confirma con un botón. La liga no se gasta hasta que confirma: la vista previa de un chat no la
          gasta.
        - Si tiene la verificación en dos pasos, se le pide su código siempre.

        La sesión que abre la liga es limitada: sólo para las empresas que esa persona conectó con tu plataforma, dura 8
        horas sin alargarse, y Usuarios, Integraciones, Mi cuenta, Plan y Mis empresas (y lo delicado: cancelar, el
        sello, la CLABE) le piden su contraseña. Desconectar la cierra.

        Sólo para plataformas verificadas (si no, 403 SOCIO_NO_VERIFICADO) y sólo si quien conectó sigue siendo dueño o
        administrador de la empresa (si no, 403 SIN_PERMISO). No revisa el plan: entrar a Factúralo y Punto no es usar la
        API.
      security: [{ llave: [], socio: [] }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { destino: { enum: [inicio, facturas, productos, configuracion], default: inicio } } } } } }
      responses:
        "200": { description: Liga, content: { application/json: { schema: { type: object, properties: { url: { type: string, description: "Del mismo origen que la API; un solo uso" }, caduca: { type: string, format: date-time } } } } } }
        "401": { description: "SIN_LLAVE, LLAVE_INVALIDA, LLAVE_REVOCADA; SIN_SECRETO_SOCIO o SOCIO_INVALIDO", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: "SOCIO_INACTIVO; SOCIO_NO_VERIFICADO (tu plataforma no está verificada); CONEXION_DE_OTRO_SOCIO (la llave no es de una conexión activa de tu plataforma); SIN_PERMISO (quien conectó ya no es dueño ni administrador de la empresa)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: "DATOS_INVALIDOS: «destino» que no existe", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

webhooks:
  aviso:
    post:
      summary: Aviso de un evento (se configura en Integraciones → Avisos)
      description: |
        Encabezados: `X-FacturaloYPunto-Evento: <evento>` y `X-FacturaloYPunto-Firma: t=<segundos>,v1=<firma>`, donde
        `v1 = HMAC-SHA256(secreto, "<t>.<cuerpo>")` en hexadecimal. Verifica la firma sobre el cuerpo crudo y que `t` no
        tenga más de 5 minutos. Responde 2xx en menos de 8 segundos; si no, se reintenta hasta cinco veces, a partir de
        1, 5 y 30 minutos, 2 horas y 12 horas después (unas 15 horas en total; las entregas pendientes se revisan cada 15
        minutos, así que cada reintento puede llegar hasta 15 minutos después). Las redirecciones no se siguen y la
        dirección debe ser pública (https); para probar en tu computadora usa un túnel https (cloudflared o ngrok). Los
        avisos que da de alta una plataforma conectada al canjear pueden ir a http://localhost o http://127.0.0.1 en
        desarrollo (nunca en producción).
        El `id` es el de la entrega: es el mismo en sus reintentos, así que úsalo para no procesarla dos veces. Cada
        cambio se avisa una sola vez (una cancelación repetida no genera otro aviso).
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Aviso" }
      responses: { "200": { description: Recibido } }

components:
  securitySchemes:
    llave: { type: http, scheme: bearer, description: "La llave de la empresa: tk_…" }
    socio: { type: apiKey, in: header, name: X-Socio-Secreto, description: "El secreto de la plataforma conectada (sk_<clave>_…), en /socios/desconectar y /sesiones" }
    socioBearer: { type: http, scheme: bearer, description: "El secreto de la plataforma conectada (sk_<clave>_…), en /socios/canjear" }
  parameters:
    Id: { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    Idempotencia: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 120 } }
  responses:
    SinLlave: { description: "SIN_LLAVE, LLAVE_INVALIDA, LLAVE_REVOCADA o LLAVE_VENCIDA (con WWW-Authenticate: Bearer)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    SinPermiso: { description: "SIN_PERMISO (la llave no tiene el permiso), FUERA_DE_PLAN (el plan no incluye la función) o CUENTA_SUSPENDIDA", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NoExiste: { description: "NO_EXISTE (no existe en esta empresa) o SIN_ARCHIVO", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Peticion: { description: "SIN_IDEMPOTENCIA (falta la Idempotency-Key) o JSON (el cuerpo no es JSON)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Idempotencia: { description: "IDEMPOTENCIA: esa Idempotency-Key ya se usó con otros datos (trae el `id` de lo que ya existe)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Validacion: { description: "Datos por corregir (DATOS_INVALIDOS, CLIENTE_INVALIDO, SIN_CLIENTE, CLIENTE_NO_ENCONTRADO, PRODUCTO_NO_ENCONTRADO, SIN_CONCEPTOS), el SAT no la aceptó (PAC_RECHAZO), o falta algo en la cuenta (CSD_NO_DISPONIBLE, FOLIOS_INSUFICIENTES, SERIE_NO_ENCONTRADA) u otro rechazo (NO_TIMBRADO). Ver la tabla de errores.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Limite: { description: "DEMASIADAS_PETICIONES: más de 120 peticiones por minuto; espera Retry-After segundos", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Interno: { description: "ERROR_INTERNO: una falla nuestra; repite con la misma Idempotency-Key", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Temporal: { description: "TIMBRADO_SIN_CONFIRMAR, RED_TIEMPO_AGOTADO, RED_SIN_CONEXION, PAC_ERROR, PAC_AUTENTICACION o PAC_FOLIOS_INSUFICIENTES: repite con la MISMA Idempotency-Key tras Retry-After (no se duplica)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    EnProceso: { description: "Otra petición con la misma Idempotency-Key la está timbrando (`aviso.codigo: EN_PROCESO`), o ya se timbró y falta guardar su XML (`aviso.codigo: ARCHIVO_NO_GUARDADO`, con `id` y `uuid`). Repite con la MISMA llave tras Retry-After.", content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Factura" }, { type: object, properties: { aviso: { type: object, properties: { codigo: { type: string }, mensaje: { type: string } } } } }] } } } }
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [codigo, mensaje]
          properties:
            codigo:
              type: string
              enum: [SIN_IDEMPOTENCIA, JSON, MULTIPART, CODIGO_INVALIDO, CODIGO_USADO, CODIGO_VENCIDO, WEBHOOK_URL, SIN_SECRETO_SOCIO, SOCIO_INVALIDO, SOCIO_INACTIVO, SOCIO_NO_VERIFICADO, CONEXION_DE_OTRO_SOCIO, SIN_LLAVE, LLAVE_INVALIDA, LLAVE_REVOCADA, LLAVE_VENCIDA, SIN_PERMISO, FUERA_DE_PLAN, CUENTA_SUSPENDIDA, EMPRESA_INACTIVA, EMISOR_NO_CORRESPONDE, NO_EXISTE, SIN_ARCHIVO, RUTA, METODO, IDEMPOTENCIA, ESTADO_NO_PERMITE, CANCELACION_CON_RELACIONADOS, TICKETS_NO_DISPONIBLES, SIN_TIMBRAR, DATOS_INVALIDOS, CLIENTE_INVALIDO, SIN_CLIENTE, CLIENTE_NO_ENCONTRADO, PRODUCTO_NO_ENCONTRADO, SIN_CONCEPTOS, PAC_RECHAZO, PAC_CSD, CSD_NO_DISPONIBLE, FOLIOS_INSUFICIENTES, SERIE_NO_ENCONTRADA, NO_TIMBRADO, MOTIVO, CANCELACION_INVALIDA, CANCELACION_FUERA_DE_PLAZO, CANCELACION_NO_APLICADA, NO_CANCELADA, PAGO_DESCARTADO, PERIODICIDAD, PERIODO, GLOBAL, SIN_TIPO_CAMBIO, TICKET, TOTAL, IVA, CORREO, ADDENDA, CONSTANCIA, BUSQUEDA, DEMASIADAS_PETICIONES, ERROR_INTERNO, NO_DISPONIBLE, CORREO_NO_SALIO, TIMBRADO_SIN_CONFIRMAR, RED_TIEMPO_AGOTADO, RED_SIN_CONEXION, PAC_ERROR, PAC_AUTENTICACION, PAC_FOLIOS_INSUFICIENTES]
            mensaje: { type: string, description: "En español, para enseñárselo TAL CUAL al usuario final (que no programa): sin rutas, sin Idempotency-Key ni nombres de campo del JSON" }
            ayuda: { type: string, description: "Pista para quien programa (qué ruta usar, qué campo mandar, qué hacer con la Idempotency-Key). No se le enseña al usuario final" }
            problemas: { type: array, items: { $ref: "#/components/schemas/Problema" } }
            reintentable: { type: boolean, description: "true: repite la MISMA petición (misma Idempotency-Key) más tarde" }
            campos: { type: object, additionalProperties: { type: string }, description: "Compatibilidad (se retirará): lo mismo que `problemas` como objeto {campo: mensaje}" }
            relacionados: { type: array, items: { type: object, properties: { id: { type: string }, tipo: { type: string }, serie: { type: string }, folio: { type: [string, "null"] } } }, description: "CANCELACION_CON_RELACIONADOS: lo que hay que cancelar primero" }
            solicitud: { type: string, description: "CANCELACION_NO_APLICADA: cómo quedó la solicitud (vigente, error o rechazado)" }
        id: { type: string, description: "El comprobante que quedó guardado, si lo hay" }
    Problema:
      type: object
      properties:
        campo: { type: string, description: "Tal como lo mandas: cliente.codigoPostal, conceptos[0].precio, usoCfdi…" }
        mensaje: { type: string }
        codigo: { type: string, description: "TIPO_INVALIDO, DESCUENTO_INVALIDO, IEPS_INVALIDO, RETENCION_INVALIDA, RETENCION_NO_APLICA, OBJETO_IMP_INVALIDO, RELACION_INVALIDA, CLAVE_PROD_SERV_NO_EXISTE, CLAVE_UNIDAD_NO_EXISTE, PRODUCTO_NO_ENCONTRADO, CLIENTE_NO_ENCONTRADO, códigos del SAT…" }
        campoCfdi: { type: string, description: "El nombre del dato en el CFDI, si es distinto (receptor.domicilioFiscalCp…)" }
        ayuda: { type: string, description: "Pista para quien programa; «mensaje» es para el usuario final" }
    EstadoFactura:
      type: string
      enum: [borrador, timbrando, timbrado, error, cancelacion_pendiente, cancelado, cancelacion_rechazada, descartado]
      description: "timbrado: vigente; cancelacion_pendiente: espera a tu cliente (saldo null); cancelado; cancelacion_rechazada: tu cliente la rechazó, sigue vigente y se puede volver a pedir; borrador/error: un intento que no se timbró (no tiene validez fiscal)"
    Factura:
      type: object
      properties:
        id: { type: string }
        serie: { type: string }
        folio: { type: [string, "null"] }
        uuid: { type: [string, "null"], description: Folio fiscal }
        total: { type: string }
        estado: { $ref: "#/components/schemas/EstadoFactura" }
        fecha: { type: [string, "null"] }
        pdf: { type: [string, "null"] }
        xml: { type: [string, "null"] }
    FacturaTimbrada:
      allOf:
        - $ref: "#/components/schemas/Factura"
        - type: object
          properties:
            avisos: { type: array, items: { $ref: "#/components/schemas/Problema" }, description: "FORMA_PAGO_POR_OMISION, CLIENTE_ACTUALIZADO, CAMPO_DESCONOCIDO (un dato que no existe y se ignoró) u otros avisos de la validación" }
    Cancelacion:
      type: object
      properties:
        motivo: { type: [string, "null"] }
        uuidSustituto: { type: [string, "null"] }
        estado: { enum: [solicitada, pendiente, cancelado, rechazado, plazo_vencido, vigente, error], description: "Cómo va la solicitud" }
        requiereAceptacion: { type: boolean }
        solicitadaEn: { type: string, format: date-time }
        expiraEn: { type: [string, "null"], format: date-time, description: "Hasta cuándo puede responder tu cliente" }
        resueltaEn: { type: [string, "null"], format: date-time }
        fueraDePlataforma: { type: boolean }
        acuse: { type: [string, "null"], description: "Liga a GET /facturas/{id}/acuse" }
    RespuestaCancelacion:
      type: object
      properties:
        id: { type: string }
        estado: { $ref: "#/components/schemas/EstadoFactura" }
        requiereAceptacion: { type: boolean }
        mensaje: { type: string }
        repetido: { type: boolean, description: "Ya se había pedido: no se volvió a mandar al SAT" }
        cancelacion: { type: object, properties: { estado: { type: string }, motivo: { type: string }, uuidSustituto: { type: [string, "null"] }, requiereAceptacion: { type: boolean }, expiraEn: { type: [string, "null"] } } }
    FacturaNueva:
      type: object
      required: [conceptos]
      properties:
        clienteId: { type: string }
        cliente:
          type: object
          description: "Cliente de México (rfc, nombre, codigoPostal, regimenFiscal) o del extranjero (extranjero: true, nombre, pais, registroFiscal). Todo como texto: un código postal como número pierde sus ceros."
          properties: { rfc: { type: string }, nombre: { type: string }, codigoPostal: { type: string }, regimenFiscal: { type: string }, usoCfdi: { type: string }, correo: { type: string, description: "Varios, separados por coma" }, extranjero: { type: boolean }, pais: { type: string }, registroFiscal: { type: string } }
        conceptos:
          type: array
          items:
            type: object
            required: [descripcion, precio]
            properties:
              descripcion: { type: string }
              cantidad: { type: string }
              precio: { type: string, description: "Precio unitario sin impuestos, hasta 6 decimales" }
              claveProdServ: { type: string, description: "Del catálogo del SAT; 01010101 por omisión" }
              claveUnidad: { type: string, description: "Del catálogo del SAT; E48 por omisión" }
              unidad: { type: string }
              iva: { type: string, description: "16 (por omisión), 8, 0, exento o no-objeto" }
              retencion: { enum: [honorarios, arrendamiento, resico, fletes, personal, otra] }
              retencionIsr: { type: string, description: "Porcentaje, sólo con retencion otra" }
              retencionIva: { type: string, description: "Porcentaje, sólo con retencion otra" }
              ieps: { type: object, properties: { tipo: { enum: [tasa, cuota] }, valor: { type: string } } }
              descuento: { type: string, description: "Un importe en pesos (no un porcentaje)" }
              productoId: { type: string, description: "Producto de tu catálogo: lo que no mandes sale de él" }
              codigo: { type: string }
              objetoImp: { type: string }
              cuentaPredial: { type: string }
              impuestosLocales: { type: array, items: { type: object } }
        usoCfdi: { type: string }
        metodoPago: { enum: [PUE, PPD], description: "PUE por omisión; PPD lleva siempre forma de pago 99" }
        formaPago: { type: string, description: "c_FormaPago como texto de dos dígitos; con PUE, 03 por omisión (con el aviso FORMA_PAGO_POR_OMISION)" }
        moneda: { type: string, description: "MXN por omisión" }
        tipoCambio: { type: string }
        condicionesPago: { type: string }
        numeroPedido: { type: string }
        observaciones: { type: string }
        enviarCorreo: { type: boolean, description: "Por omisión sí se manda al correo del cliente; false si tu sistema envía los suyos" }
        relacionados: { type: array, items: { type: object, properties: { tipoRelacion: { type: string }, uuids: { type: array, items: { type: string } } } }, description: "Un solo tipo de relación; 04 sustituye a la que se cancelará con el motivo 01" }
        informacionGlobal: { type: object, properties: { periodicidad: { type: string }, meses: { type: string }, anio: { type: integer } }, description: "Sólo para PUBLICO EN GENERAL" }
        comercioExterior: { type: object }
        cartaPorte: { type: object }
        detallista: { type: object }
        notarios: { type: object }
        ine: { type: object }
        leyendasFiscales: { type: array, items: { type: object } }
    Aviso:
      type: object
      required: [id, evento, empresaId, creado, datos]
      properties:
        id: { type: string, description: "Id de la entrega (el mismo en los reintentos: úsalo para no procesarla dos veces)" }
        evento: { enum: [factura.timbrada, factura.cancelada, factura.cancelacion_rechazada, pago.registrado, ticket.facturado, folios.por_acabarse, sello.por_vencer] }
        empresaId: { type: string }
        creado: { type: string, format: date-time }
        datos:
          oneOf:
            - title: factura.timbrada
              type: object
              properties: { id: { type: string }, serie: { type: string }, folio: { type: string }, uuid: { type: string }, total: { type: string }, origen: { type: string, description: "api, masiva, manual, recurrente o suscripcion" }, global: { type: boolean }, pago: { type: string, description: "Id del cobro en línea (Stripe o Mercado Pago), si la factura salió de ahí" } }
            - title: factura.cancelada
              type: object
              properties: { id: { type: string }, motivo: { type: [string, "null"], description: "01 a 04; null si se canceló fuera de la plataforma y no se sabe" }, sustituto: { type: string, description: "Con el motivo 01: el folio fiscal de la factura que la sustituye" }, fueraDePlataforma: { type: boolean, description: "Se canceló en el portal del SAT u otro sistema y aquí se registró" } }
            - title: factura.cancelacion_rechazada
              type: object
              properties: { id: { type: string }, motivo: { type: string }, estado: { type: string, description: "cancelacion_rechazada (tu cliente la rechazó) o timbrado (el SAT no la aplicó): sigue vigente" } }
            - title: pago.registrado
              type: object
              properties: { id: { type: string, description: "Id del complemento de pago" }, serie: { type: string }, folio: { type: string }, uuid: { type: string }, facturas: { type: array, items: { type: object, properties: { id: { type: string }, abono: { type: string } } } }, pago: { type: string, description: "Id del pago en línea, si vino de ahí" } }
            - title: ticket.facturado
              type: object
              properties: { id: { type: string, description: "Id de la factura" }, ticket: { type: string, description: "Código del ticket" }, serie: { type: string }, folio: { type: string }, uuid: { type: string }, total: { type: string } }
            - title: folios.por_acabarse
              type: object
              properties: { foliosRestantes: { type: integer }, minimo: { type: integer } }
            - title: sello.por_vencer
              type: object
              properties: { rfc: { type: string }, venceEn: { type: string, format: date-time }, dias: { type: integer }, vencido: { type: boolean } }
