# Probar la facturación sin emitir facturas

Valida una factura sin emitirla, descarga un PDF de prueba sin validez fiscal y ensaya una rectificativa antes de emitir tu primera factura real.

Cada factura que tu aplicación emite con `POST /external/v1/invoices` es un documento fiscal de verdad: lleva un número de la serie de tu organización y no se puede borrar. Para probarla hay tres rutas que calculan sin emitir, con el mismo cuerpo que usarás después. El diagnóstico general de tu aplicación (permisos, plan, avisos y llamadas rechazadas) está en [Probar tu integración](https://os.utilia.ai/docs/developers/getting-started/testing-quickstart).

## Las pruebas no numeran

La numeración de las facturas es **correlativa dentro de cada serie** (art. 6 del Real Decreto 1619/2012, que regula las obligaciones de facturación). Una factura emitida no se borra ni se renumera: si estaba mal, se corrige con una rectificativa, que es otro documento con su propio número. Si además tu organización usa VeriFactu, cada factura queda encadenada en su registro.

Por eso no hay una «serie de pruebas» ni un modo que emita facturas falsas: una factura emitida para probar sería una factura de verdad. Las pruebas pasan por tres rutas que **calculan sin emitir**:

| Para probar | Ruta | Qué devuelve |
| - | - | - |
| Una factura | `POST /external/v1/invoices/validate` | El veredicto y una vista previa de la factura, sin número. |
| Su PDF | `POST /external/v1/invoices/validate/pdf` | Un PDF de muestra marcado como documento de prueba. |
| Una rectificativa | `POST /external/v1/invoices/:id/rectifications/validate` | El veredicto y una vista previa de la rectificativa. |

Ninguna reserva número, escribe en la base de datos, envía correos ni dispara avisos. Puedes llamarlas tantas veces como quieras.

> **Atención: Los ejemplos que emiten de verdad**
>
> El guion `01-create-invoice.ts` de `examples/04-node-billing-integration` y `external-issue-invoice.ts` de `examples/sdk-js` llaman a `POST /external/v1/invoices`: emiten una factura **real y numerada**. Para probar, cambia `sdk.invoices.create` por `sdk.invoices.validate`.

## Validar una factura sin emitirla

**`POST /api/external/v1/invoices/validate`**

Comprueba una factura como si fuera a emitirse y devuelve su vista previa, sin numerarla ni guardarla. Mismo cuerpo que POST /external/v1/invoices. Basta con cualquier permiso crm:invoices:\*.

El cuerpo es **exactamente el mismo** que el de [crear una factura](https://os.utilia.ai/docs/developers/api/invoices#crear-una-factura): manda lo que mandarías para emitirla y verás lo que pasaría.

- **Permisos**: basta con cualquier permiso `crm:invoices:` (por ejemplo, solo `crm:invoices:read`). Sin ninguno, `403 INSUFFICIENT_SCOPE`. Así puedes probar antes de pedir los permisos de emisión.
- **No exige la facturación encendida.** Mientras esté apagada, la respuesta lo dice como bloqueo (`BILLING_NOT_ENABLED`).
- **No emite nada**: no reserva número, no escribe, no envía el correo, no dispara avisos y no consume la clave de idempotencia. `idempotencyKey` se acepta y se ignora, así que puedes probar con el cuerpo definitivo.
- **Responde siempre `200`** con el resultado, también cuando la factura no se podría emitir: el motivo viaja en `blockers`. Solo un cuerpo mal formado (un campo obligatorio que falta, un tipo que no es el suyo) responde `400 VALIDATION_ERROR`, igual que en el alta.

Antes de validar el contenido, [la comprobación de peticiones](https://os.utilia.ai/docs/developers/getting-started/check-request#comprobar-una-petición-sin-ejecutarla) te dice si al cuerpo le sobra algún campo mal escrito que la emisión descartaría sin error.

### Ejemplos

#### curl

```bash
curl -X POST https://os.utilia.ai/api/external/v1/invoices/validate \
  -H "X-Api-Key: $UTILIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": { "externalId": "user_1234", "email": "cliente@example.com", "name": "Ana Pérez" },
    "type": "SIMPLIFIED",
    "taxSystem": "IGIC",
    "lines": [
      { "name": "Plan Premium mensual", "quantity": 1, "unitPrice": 28.04, "taxType": "IGIC_7" }
    ],
    "payment": { "method": "CARD", "reference": "pago-1234" },
    "sendEmail": true,
    "idempotencyKey": "order_1234-invoice",
    "metadata": { "orderId": "order_1234" }
  }'
```

#### fetch

```javascript
const factura = {
  user: { externalId: 'user_1234', email: 'cliente@example.com', name: 'Ana Pérez' },
  type: 'SIMPLIFIED',
  taxSystem: 'IGIC',
  lines: [{ name: 'Plan Premium mensual', quantity: 1, unitPrice: 28.04, taxType: 'IGIC_7' }],
  payment: { method: 'CARD', reference: 'pago-1234' },
  sendEmail: true,
  idempotencyKey: 'order_1234-invoice',
  metadata: { orderId: 'order_1234' },
};

const res = await fetch('https://os.utilia.ai/api/external/v1/invoices/validate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Api-Key': process.env.UTILIA_API_KEY,
  },
  body: JSON.stringify(factura),
});
const resultado = await res.json();

if (!resultado.valid) {
  for (const bloqueo of resultado.blockers) {
    console.error(bloqueo.code, bloqueo.message);
  }
}
```

#### SDK JavaScript

```typescript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY!,
});

// El mismo objeto que pasarás a sdk.invoices.create.
const resultado = await sdk.invoices.validate(factura);
console.log(resultado.valid, resultado.preview?.totalAmount);
```

#### SDK Python

```python
from utilia_sdk.models import (
    CreateInvoiceInput,
    CreateInvoiceLine,
    CreateInvoicePayment,
    CreateInvoiceUser,
)

factura = CreateInvoiceInput(
    user=CreateInvoiceUser(external_id="user_1234", email="cliente@example.com", name="Ana Pérez"),
    type="SIMPLIFIED",
    tax_system="IGIC",
    lines=[CreateInvoiceLine(name="Plan Premium mensual", quantity=1, unit_price=28.04, tax_type="IGIC_7")],
    payment=CreateInvoicePayment(method="CARD", reference="pago-1234"),
    send_email=True,
    idempotency_key="order_1234-invoice",
    metadata={"orderId": "order_1234"},
)

resultado = await sdk.invoices.validate(factura)
print(resultado.valid, [bloqueo.code for bloqueo in resultado.blockers])
```

`sdk.invoices.validate`, `validatePdf` y `rectifications.validate` están en los SDK a partir de la 4.5.0; si la versión que puedes instalar es anterior ([Versiones y compatibilidad](https://os.utilia.ai/docs/developers/sdk/versions#lo-que-todavía-no-puedes-instalar)), llama a las rutas con `fetch` o con tu cliente HTTP. Con el SDK, el mismo objeto sirve para `validate` y, cuando todo cuadre, para `create`.

### Respuesta

```json
{
  "valid": true,
  "livemode": false,
  "blockers": [],
  "warnings": [],
  "preview": {
    "type": "SIMPLIFIED",
    "seriesCode": "DEFAULT",
    "numberFormat": "T-AAAA00000",
    "displayNumberFormat": "T-AAAA-00000",
    "issueDate": "2026-10-19",
    "taxSystem": "IGIC",
    "currency": "eur",
    "subtotal": 28.04,
    "totalTax": 1.96,
    "totalAmount": 30,
    "lines": [
      {
        "name": "Plan Premium mensual",
        "quantity": 1,
        "unitPrice": 28.04,
        "discount": 0,
        "subtotal": 28.04,
        "taxType": "IGIC_7",
        "taxRate": 7,
        "taxAmount": 1.96,
        "total": 30,
        "exemptionReason": null,
        "exempt": false
      }
    ],
    "taxes": [
      { "taxType": "IGIC_7", "taxRate": 7, "taxBase": 28.04, "taxAmount": 1.96 }
    ],
    "legalMentions": ["Factura simplificada (RD 1619/2012 art. 7)"],
    "recipient": {
      "name": "Consumidor final",
      "taxId": null,
      "address": null,
      "city": null,
      "postalCode": null,
      "country": null
    },
    "payment": { "willRegister": true, "method": "CARD", "paidAt": null },
    "email": { "willSend": true, "recipient": "cliente@example.com", "reason": null }
  }
}
```

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `valid` | `boolean` | Sí | true si POST /external/v1/invoices emitiría esta factura tal cual. false si hay al menos un bloqueo. |
| `livemode` | `boolean` | Sí | Siempre false: es una prueba y no tiene efecto fiscal. |
| `blockers` | `array` | Sí | Lo que impediría emitir, cada uno con code, message y, a veces, details. El code es el mismo errorCode que respondería el alta (por ejemplo SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED o INVOICE_RECIPIENT_INCOMPLETE con missingFields en details), más BILLING_NOT_ENABLED, INSUFFICIENT_SCOPE y APP_RESPONSIBLE_INACTIVE. |
| `warnings` | `array` | Sí | Avisos que no impiden emitir, cada uno con code y message. |
| `preview` | `objeto \| null` | Sí | La factura que se emitiría. Con bloqueos puede llegar a null: no te fíes de ella hasta que valid sea true. |
| `preview.numberFormat` | `string` | Sí | Forma del número que recibirá, sin el número en sí (AAAA es el año y los ceros, la secuencia). Nunca se reserva un número en una prueba. |
| `preview.displayNumberFormat` | `string` | Sí | La forma tal como se imprime en el PDF, con un guion entre el año y la secuencia (T-AAAA-00000). |
| `preview.issueDate` | `string` | Sí | Fecha de expedición que tendría (AAAA-MM-DD), en la zona horaria de tu organización. |
| `preview.subtotal / totalTax / totalAmount` | `number` | Sí | Base, cuota y total, redondeados como los redondea la emisión real. |
| `preview.lines` | `array` | Sí | Cada línea con su base, su tipo, su cuota, su total y, si está exenta, la causa de la exención. |
| `preview.taxes` | `array` | Sí | El desglose por tipo de impuesto que se imprimirá. |
| `preview.legalMentions` | `string[]` | Sí | Las menciones legales que llevará el PDF, en su orden. |
| `preview.recipient` | `objeto` | Sí | El destinatario tal como se imprimirá. |
| `preview.payment` | `objeto \| null` | Sí | Si mandas el bloque payment, el cobro que se registraría al emitir (willRegister, method y paidAt). null sin él. |
| `preview.email` | `objeto` | Sí | willSend dice si saldría el correo, recipient a qué dirección y reason, si no sale, por qué: NOT_REQUESTED, SEND_SCOPE_MISSING (falta crm:invoices:send) o NO_RECIPIENT. |

Una factura que no se podría emitir responde también `200`, con `valid: false`:

```json
{
  "valid": false,
  "livemode": false,
  "blockers": [
    {
      "code": "BILLING_NOT_ENABLED",
      "message": "La facturación de esta aplicación está desactivada, así que todavía no puede emitir facturas. La activa un Super Administrador de la organización en UTILIA OS, en la ficha de la aplicación, Dinero › Facturación («Activar la facturación»)."
    },
    {
      "code": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
      "message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria."
    }
  ],
  "warnings": [],
  "preview": null
}
```

La facturación apagada o un permiso de emisión que falte no impiden calcular la vista previa: con solo esos bloqueos, `preview` llega completa. Llega en `null` cuando el propio documento no se puede calcular, como en el ejemplo, por el importe de la simplificada. El único aviso que hoy puede traer `warnings` es `SEND_SCOPE_GRACE`: la aplicación no tiene `crm:invoices:send` y, por ahora, el correo saldría igual.

Cuando `valid` sea `true` con el cuerpo definitivo, ese mismo cuerpo emitirá la factura, salvo que algo cambie entre las dos llamadas (la facturación se apaga, un permiso se retira o la fecha de la última factura de la serie avanza).

## PDF de prueba

**`POST /api/external/v1/invoices/validate/pdf`**

Genera el PDF de muestra de la factura sin emitirla: número PRUEBA, sin código QR y con la marca de agua DOCUMENTO DE PRUEBA · SIN VALIDEZ FISCAL. Mismo cuerpo y mismos permisos que /invoices/validate.

Sirve para ver cómo quedará el documento con la marca, los datos fiscales y la plantilla de tu organización. Responde `application/pdf` con una muestra **sin validez fiscal**: en lugar del número lleva la palabra «PRUEBA», no lleva el código QR de VeriFactu y lleva en diagonal la marca de agua «DOCUMENTO DE PRUEBA · SIN VALIDEZ FISCAL». No lo entregues a tus usuarios ni lo guardes como factura.

Si el propio documento no se puede calcular, no hay PDF: la respuesta es `422` con el primer bloqueo que daría `/invoices/validate` como `errorCode` y la lista completa en `blockers`.

```json
{
  "statusCode": 422,
  "message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria.",
  "error": "UNPROCESSABLE_ENTITY",
  "errorCode": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
  "blockers": [
    {
      "code": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
      "message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria."
    }
  ],
  "timestamp": "2026-10-19T10:00:00.000Z",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}
```

### curl

```bash
curl -X POST https://os.utilia.ai/api/external/v1/invoices/validate/pdf \
  -H "X-Api-Key: $UTILIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @factura.json \
  --output factura-de-prueba.pdf
```

### SDK JavaScript

```typescript
import { writeFile } from 'node:fs/promises';

const bytes = await sdk.invoices.validatePdf(factura); // Uint8Array
await writeFile('factura-de-prueba.pdf', bytes);
```

### SDK Python

```python
from pathlib import Path

contenido = await sdk.invoices.validate_pdf(factura)  # bytes
Path("factura-de-prueba.pdf").write_bytes(contenido)
```

## Rectificativas de prueba

**`POST /api/external/v1/invoices/:id/rectifications/validate`**

Comprueba la rectificativa de la factura :id sin emitirla ni registrar la devolución. Mismo cuerpo y mismos permisos que POST /external/v1/invoices/:id/rectifications: sin crm:invoices:rectify, 403 INSUFFICIENT_SCOPE.

Parámetro de ruta: `id`.

Antes de devolver dinero de verdad, comprueba qué rectificativa saldría. El cuerpo es el de [emitir una rectificativa](https://os.utilia.ai/docs/developers/api/invoice-rectifications#emitir-una-rectificativa) y se comprueban sus mismos permisos: `crm:invoices:rectify`, además `crm:invoices:write` si mandas `refund` y `crm:invoices:send` si pides `sendEmail: true`. Sin `crm:invoices:rectify`, la respuesta es `403 INSUFFICIENT_SCOPE` y no enseña nada de la factura. Si lo tienes y falta alguno de los otros dos, la respuesta sigue siendo `200`, con el bloqueo `INSUFFICIENT_SCOPE`. `:id` es una factura **real** ya emitida por tu aplicación: la prueba no la toca.

La respuesta tiene la misma forma que `/invoices/validate`, con su vista previa. Si la factura no admite todavía la rectificativa (por ejemplo, porque su emisión sigue en curso), el bloqueo es `RECTIFICATION_NOT_ALLOWED` con su motivo y, si basta con esperar, `retryable` y `retryAfterSeconds` en `details`. Si la `idempotencyKey` ya tiene una operación, `warnings` trae `IDEMPOTENT_REPLAY` y `preview` llega en `null`.

```json
{
  "valid": true,
  "livemode": false,
  "blockers": [],
  "warnings": [],
  "preview": {
    "cause": "REFUND",
    "rectificationCode": "R5",
    "totalAmount": -30,
    "remainingAfter": 0,
    "issueDate": "2026-10-19",
    "refund": null,
    "willSendEmail": false
  }
}
```

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `preview.cause` | `enum` | Sí | La causa que mandaste: REFUND o CHARGEBACK. |
| `preview.rectificationCode` | `string` | Sí | El código legal que decidiría UTILIA OS (R5 para una simplificada, R1 para una ordinaria). |
| `preview.totalAmount` | `number` | Sí | Total de la rectificativa, impuestos incluidos, en negativo. |
| `preview.remainingAfter` | `number` | Sí | Lo que quedaría por rectificar de la factura original después de esta. |
| `preview.issueDate` | `string` | Sí | Fecha de expedición que tendría (AAAA-MM-DD, en la zona horaria de tu organización). |
| `preview.refund` | `objeto \| null` | Sí | La devolución que se registraría si mandas el bloque refund: reference, method, amount y refundedOn. null sin él. |
| `preview.willSendEmail` | `boolean` | Sí | Si la rectificativa se enviaría por correo al emitirla. |

En JavaScript, `sdk.invoices.rectifications.validate(facturaId, datos)`; en Python, `await sdk.invoices.rectifications.validate(factura_id, datos)` con el mismo `CreateExternalInvoiceRectificationInput` que usarías para emitirla.

## Hasta la primera factura real

El orden recomendado, de cero a la primera factura con número. El diagnóstico de tu aplicación marca solo los pasos que el servidor puede observar (ver [Primeros pasos que se marcan solos](https://os.utilia.ai/docs/developers/getting-started/testing#primeros-pasos-que-se-marcan-solos)).

1. **Concede los permisos**

   `crm:invoices:write` y `crm:invoices:issue` para emitir, `crm:invoices:send` si envías por correo y `crm:invoices:rectify` si vas a rectificar (este último solo lo concede un Super Administrador). Ver [Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).
2. **Valida la factura**

   Llama a `/invoices/validate` con el cuerpo definitivo hasta que `valid` sea `true` y `warnings` no diga nada que no esperes.
3. **Descarga el PDF de prueba**

   Revisa con `/invoices/validate/pdf` los datos fiscales, la plantilla y las menciones.
4. **Prueba los avisos**

   Envía un `INVOICE_CREATED` de ejemplo y comprueba que tu servidor verifica la firma, lo reconoce como prueba y responde `2xx`. Ver [Probar los avisos a tu servidor](https://os.utilia.ai/docs/developers/getting-started/webhook-testing).
5. **Activa la facturación**

   La enciende un Super Administrador con **Activar la facturación** en **Dinero › Facturación** de la ficha de la aplicación, con un motivo. Ver [Paso 1 de la guía de facturación](https://os.utilia.ai/docs/developers/api/billing-guide#paso-1-activar-la-facturación-de-la-aplicación).
6. **Emite la primera factura con clave de idempotencia**

   `POST /external/v1/invoices` con una `idempotencyKey` que salga de tu pedido (`order_1234-invoice`). Repite la misma petición: tiene que devolver la misma factura con `idempotent: true`, sin emitir otra.
7. **Comprueba el correo y el cobro**

   Lee `emailDelivery` en la respuesta. Si cobras fuera de la pasarela y no mandaste `payment`, registra el cobro con [`POST /external/v1/invoices/:id/mark-paid`](https://os.utilia.ai/docs/developers/api/invoice-actions#registrar-un-cobro-fuera-de-la-pasarela) y comprueba que llega `INVOICE_PAID`.
8. **Ensaya la rectificativa**

   Sobre esa factura real, comprueba con `/rectifications/validate` qué saldría si tuvieras que devolver el dinero. No emitas una rectificativa para probar: es otro documento fiscal numerado.

## Tiempos de espera y reintentos

Emitir una factura hace varias cosas en el mismo paso (numerarla, registrarla, generar su PDF y, si lo pides, registrar el cobro y enviar el correo), y a veces tarda varios segundos. Pon a tu cliente HTTP un tiempo de espera de **al menos 30 segundos** para `POST /external/v1/invoices`.

Si la petición se agota o responde `5xx`, no sabes si la factura llegó a emitirse. Repítela **con la misma `idempotencyKey` y el mismo cuerpo**: si ya se emitió, recibes esa factura con `idempotent: true`; si la primera sigue en marcha, la misma factura con `issuanceDeliveryStatus: processing` (vuelve a consultar en unos segundos); y si no llegó a emitirse, se emite ahora. Con `500 INVOICE_NUMBERING_UNAVAILABLE` no se llegó a crear nada; con `500 INVOICE_ISSUANCE_REVIEW_REQUIRED` la factura existe y pasa a revisión de tu organización.

> **Importante: Nunca reintentes con una clave nueva**
>
> Para UTILIA OS sería otra factura, con otro número, y tendrías que rectificar la que sobra. Calcula la clave a partir de tu pedido, no al azar en cada intento, o guárdala junto al pedido antes de la primera llamada.

Cuándo merece la pena reintentar y cuándo no está en [Errores, identificador de petición y reintentos](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries#cuándo-reintentar).

## El correo de la factura

La respuesta de una factura real trae dos campos pensados para tu interfaz y tu soporte:

- **`displayNumber`**: el número tal como se imprime en el PDF, con un guion entre el año y el número de orden (`T-202600001` se imprime `T-2026-00001`). Úsalo para enseñarlo. `invoiceNumber` es el número registrado: guarda y busca por él o por `id`.
- **`emailDelivery`**: qué ha pasado con el correo. Solo viaja si pediste el correo (`sendEmail: true`, o sin `sendEmail` con el interruptor de la aplicación encendido).

```json
{
  "emailDelivery": {
    "status": "retrying",
    "reason": "MAIL_SERVER_UNAVAILABLE",
    "message": "Todavía no se ha enviado por correo porque el servidor de correo no respondía. UTILIA la enviará sola en unos minutos; no hace falta que la reenvíes.",
    "attempts": 1,
    "lastAttemptAt": "2026-10-19T10:00:04.000Z"
  }
}
```

**¿Qué hago según emailDelivery.status?**

¿Qué dice status?

- sent: **Enviado**. El correo ha salido, al emitirla o después. No hace falta nada.
- retrying: **Espera**. Un fallo pasajero que UTILIA OS reintenta solo durante unas dos horas. No la reenvíes ni vuelvas a crearla.
- failed: **Resuelve el motivo y envíala con send**. Fallo definitivo, reintentos agotados (RETRIES_EXHAUSTED), sin dirección (NO_RECIPIENT) o emisión cortada (INTERRUPTED).
- withheld: **Concede el permiso o registra el cobro, y envíala con send**. Retenido a propósito: falta crm:invoices:send (SEND_SCOPE_MISSING) o el cobro de payment no se registró (PAYMENT_NOT_REGISTERED).

El envío manual es [`POST /external/v1/invoices/:id/send`](https://os.utilia.ai/docs/developers/api/invoice-actions#enviar-la-factura-por-correo). El estado del correo nunca afecta a la factura: ya está emitida y numerada. La lista completa de motivos está en [El correo de la factura](https://os.utilia.ai/docs/developers/api/invoices#el-correo-de-la-factura).

## Siguiente paso

- [Guía de facturación](https://os.utilia.ai/docs/developers/api/billing-guide): El recorrido completo, del alta del usuario al cobro recurrente.
- [Facturas](https://os.utilia.ai/docs/developers/api/invoices): El alta de facturas, sus campos, sus errores y la idempotencia.
